Refactor the render_currency function

- Move all functionality into report.py
This commit is contained in:
Oliver Walters 2026-06-19 23:28:08 +00:00
parent c4d9c00130
commit 689d9fe30c
2 changed files with 107 additions and 35 deletions

View File

@ -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()

View File

@ -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')