mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-01 21:55:43 +00:00
Report helpers (#10726)
* New report functions: - create_currency: Create a new Money object - convert_currency: Convert from one currency to another * docs * More checking on report tags * Better formatting of report errors * Add unit tests * Remove error message * Fix pathing for docs * Add type hints * Adjust unit tests
This commit is contained in:
@@ -261,6 +261,27 @@ Total Price: {% render_currency order.total_price currency='NZD' decimal_places=
|
|||||||
{% endraw %}
|
{% endraw %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### convert_currency
|
||||||
|
|
||||||
|
To convert a currency value from one currency to another, use the `convert_currency` helper function:
|
||||||
|
|
||||||
|
::: report.templatetags.report.convert_currency
|
||||||
|
options:
|
||||||
|
show_docstring_description: false
|
||||||
|
show_source: False
|
||||||
|
|
||||||
|
!!! info "Data Types"
|
||||||
|
The `money` parameter must be `Money` class instance. If not, an error will be raised.
|
||||||
|
|
||||||
|
#### create_currency
|
||||||
|
|
||||||
|
Create a `currency` instance using the `create_currency` helper function. This returns a `Money` class instance based on the provided amount and currency type.
|
||||||
|
|
||||||
|
::: report.templatetags.report.create_currency
|
||||||
|
options:
|
||||||
|
show_docstring_description: false
|
||||||
|
show_source: False
|
||||||
|
|
||||||
## Maths Operations
|
## Maths Operations
|
||||||
|
|
||||||
Simple mathematical operators are available, as demonstrated in the example template below. These operators can be used to perform basic arithmetic operations within the report template.
|
Simple mathematical operators are available, as demonstrated in the example template below. These operators can be used to perform basic arithmetic operations within the report template.
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ from django.db.models.query import QuerySet
|
|||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
|
from djmoney.money import Money
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
import common.currency
|
||||||
import common.icons
|
import common.icons
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.helpers_model
|
import InvenTree.helpers_model
|
||||||
@@ -451,7 +455,17 @@ def cast_to_type(value: Any, cast: type) -> Any:
|
|||||||
|
|
||||||
def debug_vars(x: Any, y: Any) -> str:
|
def debug_vars(x: Any, y: Any) -> str:
|
||||||
"""Return a debug string showing the types and values of two variables."""
|
"""Return a debug string showing the types and values of two variables."""
|
||||||
return f": x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
|
return f"x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
|
||||||
|
|
||||||
|
|
||||||
|
def check_nulls(func: str, *arg):
|
||||||
|
"""Check if any of the provided arguments is null.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any argument is None
|
||||||
|
"""
|
||||||
|
if any(a is None for a in arg):
|
||||||
|
raise ValidationError(f'{func}: {_("Null value provided to function")}')
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
@@ -466,11 +480,13 @@ def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be added together
|
ValidationError: If the values cannot be added together
|
||||||
"""
|
"""
|
||||||
|
check_nulls('add', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) + make_decimal(y)
|
result = make_decimal(x) + make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot add values of incompatible types') + debug_vars(x, y)
|
f'add: {_("Cannot add values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
|
|
||||||
@@ -487,11 +503,13 @@ def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be subtracted
|
ValidationError: If the values cannot be subtracted
|
||||||
"""
|
"""
|
||||||
|
check_nulls('subtract', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) - make_decimal(y)
|
result = make_decimal(x) - make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot subtract values of incompatible types') + debug_vars(x, y)
|
f'subtract: {_("Cannot subtract values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
@@ -509,11 +527,13 @@ def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be multiplied together
|
ValidationError: If the values cannot be multiplied together
|
||||||
"""
|
"""
|
||||||
|
check_nulls('multiply', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) * make_decimal(y)
|
result = make_decimal(x) * make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot multiply values of incompatible types') + debug_vars(x, y)
|
f'multiply: {_("Cannot multiply values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
@@ -531,14 +551,18 @@ def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be divided
|
ValidationError: If the values cannot be divided
|
||||||
"""
|
"""
|
||||||
|
check_nulls('divide', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) / make_decimal(y)
|
result = make_decimal(x) / make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot divide values of incompatible types') + debug_vars(x, y)
|
f'divide: {_("Cannot divide values of incompatible types")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
except ZeroDivisionError:
|
except ZeroDivisionError:
|
||||||
raise ValidationError(_('Cannot divide by zero') + debug_vars(x, y))
|
raise ValidationError(
|
||||||
|
f'divide: {_("Cannot divide by zero")}: {debug_vars(x, y)}'
|
||||||
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
|
|
||||||
@@ -555,16 +579,17 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the values cannot be used in a modulo operation
|
ValidationError: If the values cannot be used in a modulo operation
|
||||||
"""
|
"""
|
||||||
|
check_nulls('modulo', x, y)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = make_decimal(x) % make_decimal(y)
|
result = make_decimal(x) % make_decimal(y)
|
||||||
except (InvalidOperation, TypeError, ValueError):
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot perform modulo operation with values of incompatible types')
|
f'modulo: {_("Cannot perform modulo operation with values of incompatible types")} {debug_vars(x, y)}'
|
||||||
+ debug_vars(x, y)
|
|
||||||
)
|
)
|
||||||
except ZeroDivisionError:
|
except ZeroDivisionError:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Cannot perform modulo operation with divisor of zero') + debug_vars(x, y)
|
f'modulo: {_("Cannot perform modulo operation with divisor of zero")}: {debug_vars(x, y)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
return cast_to_type(result, cast)
|
return cast_to_type(result, cast)
|
||||||
@@ -576,6 +601,70 @@ def render_currency(money, **kwargs):
|
|||||||
return InvenTree.helpers_model.render_currency(money, **kwargs)
|
return InvenTree.helpers_model.render_currency(money, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def create_currency(
|
||||||
|
amount: Union[str, int, float, Decimal], currency: Optional[str] = None, **kwargs
|
||||||
|
):
|
||||||
|
"""Create a Money object, with the provided amount and currency.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
amount: The numeric amount (a numeric type or string)
|
||||||
|
currency: The currency code (e.g. 'USD', 'EUR', etc.)
|
||||||
|
|
||||||
|
Note: If the currency is not provided, the default system currency will be used.
|
||||||
|
"""
|
||||||
|
check_nulls('create_currency', amount)
|
||||||
|
|
||||||
|
currency = currency or common.currency.currency_code_default()
|
||||||
|
currency = currency.strip().upper()
|
||||||
|
|
||||||
|
if currency not in common.currency.CURRENCIES:
|
||||||
|
raise ValidationError(
|
||||||
|
f'create_currency: {_("Invalid currency code")}: {currency}'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
money = Money(amount, currency)
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValidationError(f'create_currency: {_("Invalid amount")}: {amount}')
|
||||||
|
|
||||||
|
return money
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def convert_currency(money: Money, currency: Optional[str] = None, **kwargs):
|
||||||
|
"""Convert a Money object to the specified currency.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
money: The Money instance to be converted
|
||||||
|
currency: The target currency code (e.g. 'USD', 'EUR', etc.)
|
||||||
|
|
||||||
|
Note: If the currency is not provided, the default system currency will be used.
|
||||||
|
"""
|
||||||
|
check_nulls('convert_currency', money)
|
||||||
|
|
||||||
|
if not isinstance(money, Money):
|
||||||
|
raise TypeError('convert_currency tag requires a Money instance')
|
||||||
|
|
||||||
|
currency = currency or common.currency.currency_code_default()
|
||||||
|
currency = currency.strip().upper()
|
||||||
|
|
||||||
|
if currency not in common.currency.CURRENCIES:
|
||||||
|
raise ValidationError(
|
||||||
|
f'convert_currency: {_("Invalid currency code")}: {currency}'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
converted = convert_money(money, currency)
|
||||||
|
except MissingRate:
|
||||||
|
# Re-throw error with more context
|
||||||
|
raise ValidationError(
|
||||||
|
f'convert_currency: {_("Missing exchange rate")} {money.currency} -> {currency}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def render_html_text(text: str, **kwargs):
|
def render_html_text(text: str, **kwargs):
|
||||||
"""Render a text item with some simple html tags.
|
"""Render a text item with some simple html tags.
|
||||||
@@ -622,6 +711,8 @@ def format_number(
|
|||||||
leading: Number of leading zeros (default = 0)
|
leading: Number of leading zeros (default = 0)
|
||||||
separator: Character to use as a thousands separator (default = None)
|
separator: Character to use as a thousands separator (default = None)
|
||||||
"""
|
"""
|
||||||
|
check_nulls('format_number', number)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
number = Decimal(str(number).strip())
|
number = Decimal(str(number).strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -681,6 +772,8 @@ def format_datetime(
|
|||||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
timezone: The timezone to use for the date (defaults to the server timezone)
|
||||||
fmt: The format string to use (defaults to ISO formatting)
|
fmt: The format string to use (defaults to ISO formatting)
|
||||||
"""
|
"""
|
||||||
|
check_nulls('format_datetime', dt)
|
||||||
|
|
||||||
dt = InvenTree.helpers.to_local_time(dt, timezone)
|
dt = InvenTree.helpers.to_local_time(dt, timezone)
|
||||||
|
|
||||||
if fmt:
|
if fmt:
|
||||||
@@ -698,6 +791,8 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N
|
|||||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
timezone: The timezone to use for the date (defaults to the server timezone)
|
||||||
fmt: The format string to use (defaults to ISO formatting)
|
fmt: The format string to use (defaults to ISO formatting)
|
||||||
"""
|
"""
|
||||||
|
check_nulls('format_date', dt)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = InvenTree.helpers.to_local_time(dt, timezone).date()
|
dt = InvenTree.helpers.to_local_time(dt, timezone).date()
|
||||||
except TypeError:
|
except TypeError:
|
||||||
|
|||||||
@@ -270,7 +270,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
"""Simple tests for number formatting tags."""
|
"""Simple tests for number formatting tags."""
|
||||||
fn = report_tags.format_number
|
fn = report_tags.format_number
|
||||||
|
|
||||||
self.assertEqual(fn(None), 'None')
|
# Passing None should raise an error
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
fn(None)
|
||||||
|
|
||||||
for i in [1, '1', '1.0000', ' 1 ']:
|
for i in [1, '1', '1.0000', ' 1 ']:
|
||||||
self.assertEqual(fn(i), '1')
|
self.assertEqual(fn(i), '1')
|
||||||
@@ -279,6 +281,8 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
self.assertEqual(fn(x), '10')
|
self.assertEqual(fn(x), '10')
|
||||||
|
|
||||||
self.assertEqual(fn(1234), '1234')
|
self.assertEqual(fn(1234), '1234')
|
||||||
|
self.assertEqual(fn(1234.5678, decimal_places=0), '1235')
|
||||||
|
self.assertEqual(fn(1234.5678, decimal_places=1), '1234.6')
|
||||||
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
|
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
|
||||||
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
|
self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568')
|
||||||
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
|
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57')
|
||||||
@@ -449,6 +453,55 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
self.assertEqual(report_tags.render_currency(m, min_decimal_places='a'), exp_m)
|
self.assertEqual(report_tags.render_currency(m, min_decimal_places='a'), exp_m)
|
||||||
self.assertEqual(report_tags.render_currency(m, max_decimal_places='a'), exp_m)
|
self.assertEqual(report_tags.render_currency(m, max_decimal_places='a'), exp_m)
|
||||||
|
|
||||||
|
def test_create_currency(self):
|
||||||
|
"""Test the create_currency template tag."""
|
||||||
|
m = report_tags.create_currency(1000, 'USD')
|
||||||
|
self.assertIsInstance(m, Money)
|
||||||
|
self.assertEqual(m.amount, Decimal('1000'))
|
||||||
|
self.assertEqual(str(m.currency), 'USD')
|
||||||
|
|
||||||
|
# Test with invalid currency code
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.create_currency(1000, 'QWERTY')
|
||||||
|
|
||||||
|
# Test with invalid amount
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.create_currency('abc', 'USD')
|
||||||
|
|
||||||
|
def test_convert_currency(self):
|
||||||
|
"""Test the convert_currency template tag."""
|
||||||
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
|
|
||||||
|
# Generate some dummy exchange rates
|
||||||
|
rates = {'AUD': 1.5, 'CAD': 1.7, 'GBP': 0.9, 'USD': 1.0}
|
||||||
|
|
||||||
|
# Create a dummy backend
|
||||||
|
ExchangeBackend.objects.create(name='InvenTreeExchange', base_currency='USD')
|
||||||
|
|
||||||
|
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||||
|
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for currency, rate in rates.items():
|
||||||
|
items.append(Rate(currency=currency, value=rate, backend=backend))
|
||||||
|
|
||||||
|
Rate.objects.bulk_create(items)
|
||||||
|
|
||||||
|
m = report_tags.create_currency(1000, 'GBP')
|
||||||
|
|
||||||
|
# Test with valid conversion
|
||||||
|
converted = report_tags.convert_currency(m, 'CAD')
|
||||||
|
self.assertIsInstance(converted, Money)
|
||||||
|
self.assertEqual(str(converted.currency), 'CAD')
|
||||||
|
|
||||||
|
# Test with invalid currency code
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.convert_currency(m, 'QWERTY')
|
||||||
|
|
||||||
|
# Test with missing exchange rate
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
report_tags.convert_currency(m, 'AFD')
|
||||||
|
|
||||||
def test_render_html_text(self):
|
def test_render_html_text(self):
|
||||||
"""Test the render_html_text template tag."""
|
"""Test the render_html_text template tag."""
|
||||||
# Test with a valid text
|
# Test with a valid text
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { t } from '@lingui/core/macro';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
CloseButton,
|
CloseButton,
|
||||||
Code,
|
|
||||||
Group,
|
Group,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
Overlay,
|
Overlay,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs
|
Tabs
|
||||||
@@ -91,7 +92,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
|
const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
|
||||||
|
|
||||||
const [previewItem, setPreviewItem] = useState<string>('');
|
const [previewItem, setPreviewItem] = useState<string>('');
|
||||||
const [errorOverlay, setErrorOverlay] = useState(null);
|
const [renderingErrors, setRenderingErrors] = useState<string[] | null>(null);
|
||||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
|
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
|
||||||
@@ -210,7 +211,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setErrorOverlay(null);
|
setRenderingErrors(null);
|
||||||
|
|
||||||
notifications.hide('template-preview');
|
notifications.hide('template-preview');
|
||||||
|
|
||||||
@@ -222,7 +223,19 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setErrorOverlay(error.message);
|
const msg = error?.message;
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
if (Array.isArray(msg)) {
|
||||||
|
setRenderingErrors(msg);
|
||||||
|
} else {
|
||||||
|
setRenderingErrors([msg]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRenderingErrors([
|
||||||
|
t`An unknown error occurred while rendering the preview.`
|
||||||
|
]);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsPreviewLoading(false);
|
setIsPreviewLoading(false);
|
||||||
@@ -392,10 +405,10 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
{/* @ts-ignore-next-line */}
|
{/* @ts-ignore-next-line */}
|
||||||
<PreviewArea.component ref={previewRef} />
|
<PreviewArea.component ref={previewRef} />
|
||||||
|
|
||||||
{errorOverlay && (
|
{renderingErrors && (
|
||||||
<Overlay color='red' center blur={0.2}>
|
<Overlay color='red' center blur={0.2}>
|
||||||
<CloseButton
|
<CloseButton
|
||||||
onClick={() => setErrorOverlay(null)}
|
onClick={() => setRenderingErrors(null)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '10px',
|
top: '10px',
|
||||||
@@ -410,7 +423,11 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
title={t`Error rendering template`}
|
title={t`Error rendering template`}
|
||||||
mx='10px'
|
mx='10px'
|
||||||
>
|
>
|
||||||
<Code>{errorOverlay}</Code>
|
<List>
|
||||||
|
{renderingErrors.map((error, index) => (
|
||||||
|
<ListItem key={index}>{error}</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user