diff --git a/CHANGELOG.md b/CHANGELOG.md
index 678ed6144c..dccb9c81bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md
index d8e51ee947..79ae0c933e 100644
--- a/docs/docs/report/helpers.md
+++ b/docs/docs/report/helpers.md
@@ -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
Line Item Unit Pricing:
{% for line in order.lines %}
-
+
- {% render_currency line.price currency=order.supplier.currency %}
{% endfor %}
diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md
index 244eb48e19..c855f4b15a 100644
--- a/docs/docs/settings/global.md
+++ b/docs/docs/settings/global.md
@@ -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") }}
diff --git a/src/backend/InvenTree/InvenTree/format.py b/src/backend/InvenTree/InvenTree/format.py
index 5c389b6cea..d5a0ca5f32 100644
--- a/src/backend/InvenTree/InvenTree/format.py
+++ b/src/backend/InvenTree/InvenTree/format.py
@@ -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,
diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py
index f17b99e930..cf9dec2307 100644
--- a/src/backend/InvenTree/common/setting/system.py
+++ b/src/backend/InvenTree/common/setting/system.py
@@ -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,
diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py
index de876bc64e..17b373b6cf 100644
--- a/src/backend/InvenTree/common/tests.py
+++ b/src/backend/InvenTree/common/tests.py
@@ -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']:
diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py
index 87cf7ab03e..2a9d2cde24 100644
--- a/src/backend/InvenTree/report/templatetags/report.py
+++ b/src/backend/InvenTree/report/templatetags/report.py
@@ -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()
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index 85facf5e55..b9f3a0c35e 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -197,7 +197,7 @@ export default function SystemSettings() {