From 689d9fe30c3a39e34533ca39bd879b8f7da617ab Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 19 Jun 2026 23:28:08 +0000 Subject: [PATCH] Refactor the render_currency function - Move all functionality into report.py --- .../InvenTree/report/templatetags/report.py | 112 +++++++++++++----- src/backend/InvenTree/report/test_tags.py | 30 ++++- 2 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index e570905f81..867222bbd8 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -11,7 +11,6 @@ from typing import Any, Optional from django import template from django.apps.registry import apps -from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import SuspiciousFileOperation, ValidationError from django.core.files.storage import default_storage @@ -23,6 +22,7 @@ from django.utils.translation import gettext_lazy as _ from babel.dates import format_date as babel_format_date from babel.dates import format_datetime as babel_format_datetime from babel.numbers import format_decimal as babel_format_decimal +from babel.numbers import parse_pattern from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money @@ -776,26 +776,89 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any: return cast_to_type(result, cast) -def _get_report_locale(override: Optional[str] = None) -> str: - """Return the locale to use for report formatting.""" - locale = override or settings.LANGUAGE_CODE +@register.simple_tag +def render_currency( + money, + decimal_places: Optional[int] = None, + currency: Optional[str] = None, + multiplier: Optional[Decimal] = None, + max_decimal_places: Optional[int] = None, + include_symbol: bool = True, + fmt: Optional[str] = None, + locale: Optional[str] = None, + **kwargs, +) -> str: + """Render a currency / Money object to a formatted string. - # Run a check to ensure that the locale is valid (i.e. can be used by babel) - try: - get_locale(locale) - except Exception: - raise ValidationError( - f'Invalid locale specified for report formatting: {locale}' + Arguments: + money: The Money instance to be rendered + currency: Optionally convert to the specified currency before rendering + multiplier: Optional multiplier to apply to the amount before rendering + decimal_places: Minimum (forced) decimal places, e.g. decimal_places=2 gives '.00'. Defaults to the locale/currency standard. + max_decimal_places: Maximum decimal places (optional digits beyond decimal_places), e.g. max_decimal_places=4 allows up to 4. + include_symbol: If True, include the currency symbol in the output + fmt: Optional Babel number pattern string. When provided, takes priority over all decimal_places options. + locale: Optional locale override (e.g. 'en-us', 'de-de'). Defaults to server LANGUAGE_CODE. + """ + if money in [None, '']: + return '-' + + if not isinstance(money, Money): + try: + money = Money( + Decimal(str(money)), + currency or get_global_setting('INVENTREE_DEFAULT_CURRENCY'), + ) + except Exception: + raise ValidationError( + 'render_currency: invalid money value: ' + repr(money) + ) + + if currency is not None: + try: + money = convert_money(money, currency) + except Exception: + pass + + if multiplier is not None: + try: + money *= Decimal(str(multiplier).strip()) + except Exception: + raise ValidationError( + 'render_currency: invalid multiplier value: ' + repr(multiplier) + ) + + locale = get_locale(locale) + + # If a custom fmt pattern is applied, that overrides other formatting options + if fmt: + pattern = parse_pattern(fmt) + return pattern.apply( + money.amount, + locale, + currency=money.currency.code if include_symbol else '', + currency_digits=False, + decimal_quantization=True, ) - return locale + pattern = locale.currency_formats['standard'] + if decimal_places is None or not isinstance(decimal_places, (int, float)): + decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0) -@register.simple_tag -def render_currency(money, **kwargs): - """Render a currency / Money object.""" - kwargs['locale'] = _get_report_locale(kwargs.get('locale')) - return InvenTree.helpers_model.render_currency(money, **kwargs) + if max_decimal_places is None or not isinstance(max_decimal_places, (int, float)): + max_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6) + + pattern.frac_prec = (decimal_places, max(decimal_places, max_decimal_places)) + + return pattern.apply( + money.amount, + locale, + currency=money.currency.code if include_symbol else '', + currency_digits=decimal_places is None and max_decimal_places is None, + decimal_quantization=decimal_places is not None + or max_decimal_places is not None, + ) @register.simple_tag @@ -970,8 +1033,7 @@ def format_number( # No decimal places specified, allow any number of decimal places (up to the precision of the Decimal) fmt += '.####################' - resolved_locale = _get_report_locale(locale) - babel_locale = get_locale(resolved_locale) + babel_locale = get_locale(locale) return babel_format_decimal( number, format=fmt, locale=babel_locale, numbering_system='latn' @@ -1000,11 +1062,7 @@ def format_datetime( if fmt: return dt.strftime(fmt) - resolved_locale = _get_report_locale(locale) - if resolved_locale: - return babel_format_datetime( - dt, format='medium', locale=get_locale(resolved_locale) - ) + return babel_format_datetime(dt, format='medium', locale=get_locale(locale)) # Final backup - return an ISO formatted string return dt.isoformat() @@ -1035,13 +1093,7 @@ def format_date( if fmt: return dt.strftime(fmt) - resolved_locale = _get_report_locale(locale) - if resolved_locale: - return babel_format_date( - dt, format='medium', locale=get_locale(resolved_locale) - ) - - return dt.isoformat() + return babel_format_date(dt, format='medium', locale=get_locale(locale)) @register.simple_tag() diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index da09ebcadf..3e40ae0a65 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -14,6 +14,7 @@ from djmoney.money import Money from PIL import Image from common.models import InvenTreeSetting, Parameter, ParameterTemplate +from common.settings import set_global_setting from InvenTree.unit_test import InvenTreeTestCase from part.models import Part from part.test_api import PartImageTestMixin @@ -556,21 +557,24 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): self.assertEqual(report_tags.render_currency(m, decimal_places=3), '$1,234.560') self.assertEqual( report_tags.render_currency( - Money(1234, 'USD'), currency='EUR', min_decimal_places=3 + Money(1234, 'USD'), currency='EUR', decimal_places=3 ), '$1,234.000', ) + + set_global_setting('PRICING_DECIMAL_PLACES_MIN', 2) + self.assertEqual( report_tags.render_currency( Money(1234, 'USD'), currency='EUR', max_decimal_places=1 ), - '$1,234.0', + '$1,234.00', ) # Test with non-currency values self.assertEqual( - report_tags.render_currency(1234.45, currency='USD', decimal_places=2), - '$1,234.45', + report_tags.render_currency(1234.45, currency='USD', decimal_places=5), + '$1,234.45000', ) # Test with an invalid amount @@ -581,7 +585,6 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): report_tags.render_currency(m, multiplier='quork') self.assertEqual(report_tags.render_currency(m, 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) # Test locale override โ€” different locales render USD differently @@ -589,6 +592,23 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): self.assertEqual(report_tags.render_currency(m, locale='en-gb'), 'US$1,234.56') self.assertEqual(report_tags.render_currency(m, locale='en-au'), 'USD1,234.56') + # Test with custom fmt pattern + # Pattern without currency placeholder โ€” no symbol in output + self.assertEqual(report_tags.render_currency(m, fmt='#,##0.00'), '1,234.56') + # Pattern with currency placeholder โ€” symbol rendered per locale + self.assertEqual( + report_tags.render_currency(m, fmt='ยค#,##0.0000', locale='en-us'), + '$1,234.5600', + ) + # fmt + locale: de-de uses dot thousands, comma decimal + self.assertEqual( + report_tags.render_currency(m, fmt='#,##0.00', locale='de-de'), '1.234,56' + ) + # fmt takes priority over decimal_places + self.assertEqual( + report_tags.render_currency(m, fmt='0.0000', decimal_places=2), '1234.5600' + ) + def test_render_currency_locale_override(self): """Explicit locale= kwarg takes priority over global setting and system locale.""" m = Money(1234.56, 'USD')