Refactoring:

- Change REPORT_CURRENCY_LOCALE to REPORT_LOCALE
This commit is contained in:
Oliver Walters 2026-06-19 07:30:26 +00:00
parent c56a9cc335
commit 72c58a4a7a
8 changed files with 98 additions and 47 deletions

View File

@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#12208](https://github.com/inventree/InvenTree/pull/12208) adds custom locale support for currency rendering within reports.
- [#12208](https://github.com/inventree/InvenTree/pull/12208) adds custom locale support for rendering currencies, dates and numbers within reports. This allows users to specify a custom locale for report rendering, which can be used to control the formatting of dates, numbers and currency values in the generated reports. The custom locale can be set via the `REPORT_LOCALE` system setting, and if not set, the system will fall back to using the default system locale for report rendering.
- [#12204](https://github.com/inventree/InvenTree/pull/12204) adds new filtering options to PartCategoryTree and StockLocationTree API endpoints, allowing tree data to be fetched dynamically
- [#12165](https://github.com/inventree/InvenTree/pull/12165) adds support for parameters against the PartCategory model
- [#12103](https://github.com/inventree/InvenTree/pull/12103) adds column-based filtering to table views in the user interface. This extends the existing table filtering functionality by allowing users to apply filters directly to individual columns.

View File

@ -393,10 +393,10 @@ The locale controls how the currency symbol is rendered. Different locales produ
The locale used by `render_currency` is resolved in the following priority order:
1. **Explicit `locale=` argument** in the template tag — highest priority, always wins
2. **`REPORT_CURRENCY_LOCALE` global setting** — applied to all reports when set, with no per-tag argument needed
2. **`REPORT_LOCALE` global setting** — applied to all reports when set, with no per-tag argument needed
3. **Server `LANGUAGE_CODE`** — the fallback when neither of the above is configured
To apply a consistent locale across all reports without modifying every template, set the `REPORT_CURRENCY_LOCALE` global setting (via *Settings → Reporting → Report Currency Locale*). Individual templates can still override it per-tag using the `locale=` argument.
To apply a consistent locale across all reports without modifying every template, set the `REPORT_LOCALE` global setting (via *Settings → Reporting → Report Currency Locale*). Individual templates can still override it per-tag using the `locale=` argument.
#### Example
@ -407,7 +407,7 @@ To apply a consistent locale across all reports without modifying every template
<em>Line Item Unit Pricing:</em>
<ul>
{% for line in order.lines %}
<!-- Locale resolved from REPORT_CURRENCY_LOCALE setting, or server LANGUAGE_CODE -->
<!-- Locale resolved from REPORT_LOCALE setting, or server LANGUAGE_CODE -->
<li>{% render_currency line.price currency=order.supplier.currency %}</li>
{% endfor %}
</ul>

View File

@ -142,7 +142,7 @@ Configuration of report generation:
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("REPORT_ENABLE") }}
{{ globalsetting("REPORT_CURRENCY_LOCALE") }}
{{ globalsetting("REPORT_LOCALE") }}
{{ globalsetting("REPORT_DEFAULT_PAGE_SIZE") }}
{{ globalsetting("REPORT_DEBUG_MODE") }}
{{ globalsetting("REPORT_FETCH_URLS") }}

View File

@ -184,6 +184,22 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str:
return result.group(name)
def get_locale(locale: Optional[str] = None) -> Locale:
"""Resolve and return a babel Locale.
Args:
locale: Optional locale string (e.g. 'en-us'). Falls back to LANGUAGE_CODE.
Raises:
ValidationError: If the locale string is invalid.
"""
language = locale or settings.LANGUAGE_CODE
try:
return Locale.parse(translation.to_locale(language))
except (UnknownLocaleError, ValueError) as e:
raise ValidationError(f"Invalid locale '{language}' - {e}")
def format_money(
money: Money,
decimal_places: Optional[int] = None,
@ -206,22 +222,18 @@ def format_money(
Raises:
ValueError: format string is incorrectly specified
"""
language = locale or settings.LANGUAGE_CODE
try:
locale = Locale.parse(translation.to_locale(language))
except (UnknownLocaleError, ValueError) as e:
raise ValidationError(f"format_money: Invalid locale '{language}' - {e}")
resolved_locale = get_locale(locale)
if fmt:
pattern = parse_pattern(fmt)
else:
pattern = locale.currency_formats['standard']
pattern = resolved_locale.currency_formats['standard']
if decimal_places is not None:
pattern.frac_prec = (decimal_places, decimal_places)
result = pattern.apply(
money.amount,
locale,
resolved_locale,
currency=money.currency.code if include_symbol else '',
currency_digits=decimal_places is None,
decimal_quantization=decimal_places is not None,

View File

@ -671,10 +671,10 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'REPORT_CURRENCY_LOCALE': {
'name': _('Report Currency Locale'),
'REPORT_LOCALE': {
'name': _('Report Locale'),
'description': _(
'Locale to use when rendering currency values in reports (e.g. en-US, fr-FR). Leave blank to use the system locale setting.'
'Locale to use when rendering dates, numbers, and currency values in reports (e.g. en-US, de-DE). Leave blank to use the system locale setting.'
),
'default': '',
'validator': common.validators.validate_locale,

View File

@ -621,10 +621,8 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
self.assertEqual(codes, 'AUD,USD,GBP')
def test_report_currency_locale(self):
"""Test validation of the REPORT_CURRENCY_LOCALE setting."""
url = reverse(
'api-global-setting-detail', kwargs={'key': 'REPORT_CURRENCY_LOCALE'}
)
"""Test validation of the REPORT_LOCALE setting (locale string for report formatting)."""
url = reverse('api-global-setting-detail', kwargs={'key': 'REPORT_LOCALE'})
# Valid locale values are accepted
for valid in ['en-us', 'en-gb', 'en-au', 'de-de', 'fr-fr']:

View File

@ -11,6 +11,7 @@ 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
@ -19,6 +20,9 @@ from django.db.models.query import QuerySet
from django.utils.safestring import SafeString, mark_safe
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 djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
@ -32,6 +36,7 @@ import InvenTree.helpers_model
import report.helpers
from common.settings import get_global_setting
from company.models import Company
from InvenTree.format import get_locale
from part.models import Part
register = template.Library()
@ -771,11 +776,21 @@ 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) -> Optional[str]:
"""Return the locale to use for report formatting.
Priority: explicit override > REPORT_LOCALE global setting > None (caller falls back to LANGUAGE_CODE).
"""
if override:
return override
return get_global_setting('REPORT_LOCALE', cache=True) or settings.LANGUAGE_CODE
@register.simple_tag
def render_currency(money, **kwargs):
"""Render a currency / Money object."""
if 'locale' not in kwargs:
if locale := get_global_setting('REPORT_CURRENCY_LOCALE', cache=True):
if locale := _get_report_locale():
kwargs['locale'] = locale
return InvenTree.helpers_model.render_currency(money, **kwargs)
@ -879,6 +894,7 @@ def format_number(
integer: bool = False,
leading: int = 0,
separator: Optional[str] = None,
locale: Optional[str] = None,
) -> str:
"""Render a number with optional formatting options.
@ -888,7 +904,8 @@ def format_number(
multiplier: Optional multiplier to apply to the number before formatting
integer: Boolean, whether to render the number as an integer
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, ignored when locale is active)
locale: Optional locale override (e.g. 'en-us', 'de-de'). When set, babel controls decimal and thousands separators.
"""
check_nulls('format_number', number)
@ -902,33 +919,35 @@ def format_number(
number *= Decimal(str(multiplier).strip())
if integer:
# Convert to integer
number = Decimal(int(number))
# Normalize the number (remove trailing zeroes)
number = number.normalize()
if decimal_places is not None:
try:
decimal_places = int(decimal_places)
number = round(number, decimal_places)
except ValueError:
pass
decimal_places = None
# Re-encode, and normalize again
# Ensure that the output never uses scientific notation
value = Decimal(number)
value = (
value.quantize(Decimal(1))
if value == value.to_integral()
else value.normalize()
)
resolved_locale = _get_report_locale(locale)
if separator:
value = f'{value:,}'
value = value.replace(',', separator)
if resolved_locale:
babel_locale = get_locale(resolved_locale)
fmt = '#,##0.' + '0' * decimal_places if decimal_places is not None else None
value = babel_format_decimal(number, format=fmt, locale=babel_locale)
else:
value = f'{value}'
# Normalize the number (remove trailing zeroes), avoid scientific notation
value = Decimal(number)
value = (
value.quantize(Decimal(1))
if value == value.to_integral()
else value.normalize()
)
if separator:
value = f'{value:,}'
value = value.replace(',', separator)
else:
value = f'{value}'
if leading is not None:
try:
@ -942,14 +961,18 @@ def format_number(
@register.simple_tag
def format_datetime(
dt: datetime, timezone: Optional[str] = None, fmt: Optional[str] = None
dt: datetime,
timezone: Optional[str] = None,
fmt: Optional[str] = None,
locale: Optional[str] = None,
):
"""Format a datetime object for display.
Arguments:
dt: The datetime object to format
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 strftime format string to use. When provided, takes priority over locale.
locale: Optional locale override (e.g. 'en-us', 'de-de'). Used for locale-aware formatting when no fmt is given.
"""
check_nulls('format_datetime', dt)
@ -957,18 +980,30 @@ def format_datetime(
if fmt:
return dt.strftime(fmt)
else:
return dt.isoformat()
resolved_locale = _get_report_locale(locale)
if resolved_locale:
return babel_format_datetime(
dt, format='medium', locale=get_locale(resolved_locale)
)
return dt.isoformat()
@register.simple_tag
def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = None):
def format_date(
dt: date,
timezone: Optional[str] = None,
fmt: Optional[str] = None,
locale: Optional[str] = None,
):
"""Format a date object for display.
Arguments:
dt: The date to format
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 strftime format string to use. When provided, takes priority over locale.
locale: Optional locale override (e.g. 'en-us', 'de-de'). Used for locale-aware formatting when no fmt is given.
"""
check_nulls('format_date', dt)
@ -979,8 +1014,14 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N
if fmt:
return dt.strftime(fmt)
else:
return dt.isoformat()
resolved_locale = _get_report_locale(locale)
if resolved_locale:
return babel_format_date(
dt, format='medium', locale=get_locale(resolved_locale)
)
return dt.isoformat()
@register.simple_tag()

View File

@ -197,7 +197,7 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'REPORT_ENABLE',
'REPORT_CURRENCY_LOCALE',
'REPORT_LOCALE',
'REPORT_DEFAULT_PAGE_SIZE',
'REPORT_DEBUG_MODE',
'REPORT_FETCH_URLS',