diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index f6034fe523..59c63f58c0 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -261,6 +261,27 @@ Total Price: {% render_currency order.total_price currency='NZD' decimal_places= {% 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 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. diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index 41e6a24445..a94fc1cc97 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -15,8 +15,12 @@ from django.db.models.query import QuerySet from django.utils.safestring import SafeString, mark_safe 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 +import common.currency import common.icons import InvenTree.helpers 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: """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() @@ -466,11 +480,13 @@ def add(x: Any, y: Any, cast: Optional[type] = None) -> Any: Raises: ValidationError: If the values cannot be added together """ + check_nulls('add', x, y) + try: result = make_decimal(x) + make_decimal(y) except (InvalidOperation, TypeError, ValueError): 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) @@ -487,11 +503,13 @@ def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any: Raises: ValidationError: If the values cannot be subtracted """ + check_nulls('subtract', x, y) + try: result = make_decimal(x) - make_decimal(y) except (InvalidOperation, TypeError, ValueError): 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) @@ -509,11 +527,13 @@ def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any: Raises: ValidationError: If the values cannot be multiplied together """ + check_nulls('multiply', x, y) + try: result = make_decimal(x) * make_decimal(y) except (InvalidOperation, TypeError, ValueError): 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) @@ -531,14 +551,18 @@ def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any: Raises: ValidationError: If the values cannot be divided """ + check_nulls('divide', x, y) + try: result = make_decimal(x) / make_decimal(y) except (InvalidOperation, TypeError, ValueError): 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: - 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) @@ -555,16 +579,17 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any: Raises: ValidationError: If the values cannot be used in a modulo operation """ + check_nulls('modulo', x, y) + try: result = make_decimal(x) % make_decimal(y) except (InvalidOperation, TypeError, ValueError): raise ValidationError( - _('Cannot perform modulo operation with values of incompatible types') - + debug_vars(x, y) + f'modulo: {_("Cannot perform modulo operation with values of incompatible types")} {debug_vars(x, y)}' ) except ZeroDivisionError: 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) @@ -576,6 +601,70 @@ def 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 def render_html_text(text: str, **kwargs): """Render a text item with some simple html tags. @@ -622,6 +711,8 @@ def format_number( leading: Number of leading zeros (default = 0) separator: Character to use as a thousands separator (default = None) """ + check_nulls('format_number', number) + try: number = Decimal(str(number).strip()) except Exception: @@ -681,6 +772,8 @@ def format_datetime( timezone: The timezone to use for the date (defaults to the server timezone) fmt: The format string to use (defaults to ISO formatting) """ + check_nulls('format_datetime', dt) + dt = InvenTree.helpers.to_local_time(dt, timezone) 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) fmt: The format string to use (defaults to ISO formatting) """ + check_nulls('format_date', dt) + try: dt = InvenTree.helpers.to_local_time(dt, timezone).date() except TypeError: diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index 8c07932520..4bb55c7418 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -270,7 +270,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): """Simple tests for number formatting tags.""" 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 ']: self.assertEqual(fn(i), '1') @@ -279,6 +281,8 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): self.assertEqual(fn(x), '10') 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=3), '1234.568') 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, 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): """Test the render_html_text template tag.""" # Test with a valid text diff --git a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx index c8423cd264..2cf8633136 100644 --- a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx +++ b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx @@ -2,8 +2,9 @@ import { t } from '@lingui/core/macro'; import { Alert, CloseButton, - Code, Group, + List, + ListItem, Overlay, Stack, Tabs @@ -91,7 +92,7 @@ export function TemplateEditor(props: Readonly) { const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false); const [previewItem, setPreviewItem] = useState(''); - const [errorOverlay, setErrorOverlay] = useState(null); + const [renderingErrors, setRenderingErrors] = useState(null); const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [editorValue, setEditorValue] = useState(editors[0].key); @@ -210,7 +211,7 @@ export function TemplateEditor(props: Readonly) { ) ) .then(() => { - setErrorOverlay(null); + setRenderingErrors(null); notifications.hide('template-preview'); @@ -222,7 +223,19 @@ export function TemplateEditor(props: Readonly) { }); }) .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(() => { setIsPreviewLoading(false); @@ -392,10 +405,10 @@ export function TemplateEditor(props: Readonly) { {/* @ts-ignore-next-line */} - {errorOverlay && ( + {renderingErrors && ( setErrorOverlay(null)} + onClick={() => setRenderingErrors(null)} style={{ position: 'absolute', top: '10px', @@ -410,7 +423,11 @@ export function TemplateEditor(props: Readonly) { title={t`Error rendering template`} mx='10px' > - {errorOverlay} + + {renderingErrors.map((error, index) => ( + {error} + ))} + )}