From ca16e6ec0a2ec137392e8f3bcf62d29559752a8e Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 20 Jun 2026 11:00:12 +1000 Subject: [PATCH] Report locale updates (#12208) * Optional 'locale' arg to format_money - Allows override of system locale when generating reports * Updated documentation * Add unit tests * Handle invalid locale * Handle invalid locale * Add new global setting to control currency locale in reports * Use setting in reports * Add CHANGELOG entry * Further unit tests * Add unit tests for new setting * Update docs * More docs * Refactoring: - Change REPORT_CURRENCY_LOCALE to REPORT_LOCALE * Extend unit testing * Refactor format_number * Add unit tests for explicit format strings * Update examples for format_date * Updated unit tests * Cleanup unit tests * Fix more tests * Adjust wording * Remove global setting - simplify code * Simplify unit tests * Revert 'min_digits' to 'leading' * Fix docs * Refactor the render_currency function - Move all functionality into report.py * Cleanup duplicate code * Updated docs * Allow user to specify date_format * Add support for 'leading' digits in render_currency * Bug fix * Fix unit test * Add tests for "include_symbol" --- CHANGELOG.md | 1 + docs/docs/concepts/pricing.md | 6 + docs/docs/report/helpers.md | 266 +++++++++++++++++- src/backend/InvenTree/InvenTree/format.py | 47 ---- .../InvenTree/InvenTree/helpers_model.py | 84 ------ src/backend/InvenTree/InvenTree/tests.py | 29 -- src/backend/InvenTree/common/validators.py | 15 + .../InvenTree/report/templatetags/report.py | 250 ++++++++++++---- src/backend/InvenTree/report/test_tags.py | 261 +++++++++++++++-- 9 files changed, 711 insertions(+), 248 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a4265b85..930b77ad24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +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 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. - [#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/concepts/pricing.md b/docs/docs/concepts/pricing.md index 037387e019..8e1e5a5345 100644 --- a/docs/docs/concepts/pricing.md +++ b/docs/docs/concepts/pricing.md @@ -63,3 +63,9 @@ Currency exchange rates are updated periodically, using the configured currency ## Pricing Settings Refer to the [global settings](../settings/global.md#pricing-and-currency) documentation for more information on available currency settings. + +## Rendering Currencies in Reports + +Currency values can be rendered in report templates using the [`render_currency`](../report/helpers.md#render_currency) helper function. This function formats a currency amount according to a locale, and supports currency conversion within the template. + +See the [report helpers documentation](../report/helpers.md#currency-formatting) for full details and examples. diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index c8811f561c..1cde507797 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -320,15 +320,86 @@ The helper function `format_number` allows for some common number formatting opt show_docstring_description: false show_source: False -#### Example +#### Examples ```html {% raw %} {% load report %} -{% format_number 3.14159265359 decimal_places=5, leading=3 %} - + + +{% format_number 3.14159265359 decimal_places=5 %} + + + +{% format_number 3.14159265359 decimal_places=5 leading=3 %} + + + {% format_number 3.14159265359 integer=True %} + + +{% format_number 9988776.5 decimal_places=2 separator=True %} + + + +{% format_number 9988776.5 decimal_places=2 separator=True locale='de-de' %} + + + +{% format_number 0.175 multiplier=100 decimal_places=1 %} + + + +{% format_number 1234.5 decimal_places=2 max_decimal_places=6 %} + + +{% endraw %} +``` + +#### Custom Format Strings + +The `fmt` argument accepts a [Unicode number pattern](https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns) string (the same syntax used by [Babel](https://babel.pocoo.org/en/latest/numbers.html)). When `fmt` is provided it takes complete priority over the `decimal_places`, `max_decimal_places`, `leading`, and `separator` arguments — those arguments are silently ignored. + +The `integer` and `multiplier` arguments **are** still applied to the number before the format string is used. + +| Symbol | Meaning | +| --- | --- | +| `0` | Required digit — always rendered, even if zero | +| `#` | Optional digit — suppressed when not significant | +| `,` | Grouping separator (position defines group size) | +| `.` | Decimal separator | + +Common patterns: + +| Pattern | Example output | +| --- | --- | +| `0` | `1235` | +| `#,##0` | `1,235` | +| `0.00` | `1234.57` | +| `#,##0.00` | `1,234.57` | +| `000` | `007` | + +```html +{% raw %} +{% load report %} + + +{% format_number 1234.5678 fmt='0.00' %} + + + +{% format_number 1234.5678 fmt='#,##0.00' %} + + + +{% format_number 1234.5678 fmt='#,##0.00' locale='de-de' %} + + + +{% format_number 9988776655.4321 fmt='#,##0' integer=True %} + + {% endraw %} ``` @@ -336,6 +407,25 @@ The helper function `format_number` allows for some common number formatting opt For rendering date and datetime information, the following helper functions are available: +Both functions resolve their output using the following priority order: + +1. **`fmt=` argument** — a [strftime format string](https://docs.python.org/3/library/datetime.html#format-codes). When provided, this takes full priority; `locale` and `date_format` are ignored. +2. **`locale=` argument** — when no `fmt` is given, Babel formats the value using the style set by `date_format` (default `medium`). +3. **Server `LANGUAGE_CODE`** — used as the locale when no `locale=` argument is supplied. + +#### Date Format Styles + +The `date_format` argument controls how Babel renders the date when locale-aware formatting is used. The four named styles are: + +| Style | `format_date` example (en-us, 2025-01-12) | `format_datetime` example (en-us, 2025-01-12 14:30) | +| --- | --- | --- | +| `full` | `Sunday, January 12, 2025` | `Sunday, January 12, 2025 at 2:30:00 PM UTC` | +| `long` | `January 12, 2025` | `January 12, 2025 at 2:30:00 PM UTC` | +| `medium` *(default)* | `Jan 12, 2025` | `Jan 12, 2025, 2:30:00 PM` | +| `short` | `1/12/25` | `1/12/25, 2:30 PM` | + +The exact output varies by locale — the table above uses `en-us`. + ### format_date ::: report.templatetags.report.format_date @@ -343,6 +433,35 @@ For rendering date and datetime information, the following helper functions are show_docstring_description: false show_source: False +#### Examples + +```html +{% raw %} +{% load report %} + + +{% format_date my_date %} + + + +{% format_date my_date fmt="%d/%m/%Y" %} + + + +{% format_date my_date locale='en-us' %} + + + +{% format_date my_date locale='en-us' date_format='short' %} + + + +{% format_date my_date locale='en-us' date_format='full' %} + + +{% endraw %} +``` + ### format_datetime ::: report.templatetags.report.format_datetime @@ -350,20 +469,31 @@ For rendering date and datetime information, the following helper functions are show_docstring_description: false show_source: False -### Date Formatting - -If not specified, these methods return a result which uses ISO formatting. Refer to the [datetime format codes](https://docs.python.org/3/library/datetime.html#format-codes) for more information! | - - -### Example - -A simple example of using the date formatting helper functions: +#### Examples ```html {% raw %} {% load report %} -Date: {% format_date my_date timezone="Australia/Sydney" %} -Datetime: {% format_datetime my_datetime format="%d-%m-%Y %H:%M%S" %} + + +{% format_datetime my_datetime %} + + + +{% format_datetime my_datetime fmt="%d-%m-%Y %H:%M" %} + + + +{% format_datetime my_datetime locale='en-us' %} + + + +{% format_datetime my_datetime locale='de-de' date_format='short' %} + + + +{% format_datetime my_datetime timezone="Australia/Sydney" locale='en-au' %} + {% endraw %} ``` @@ -373,11 +503,115 @@ Datetime: {% format_datetime my_datetime format="%d-%m-%Y %H:%M%S" %} The helper function `render_currency` allows for simple rendering of currency data. This function can also convert the specified amount of currency into a different target currency: -::: InvenTree.helpers_model.render_currency +::: report.templatetags.report.render_currency options: show_docstring_description: false show_source: False +#### Decimal Places + +When no decimal place arguments are provided, the locale/currency standard is used (e.g. 2 places for USD, 0 for JPY). + +`decimal_places` and `max_decimal_places` work the same way as in [`format_number`](#format_number): + +| Argument | Effect | +| --- | --- | +| `decimal_places=N` | Forces exactly N decimal digits (zero-padded) | +| `max_decimal_places=M` | Allows up to M decimal digits, suppressing trailing zeros beyond `decimal_places` | +| Both set | Forced minimum of `decimal_places`, optional up to `max_decimal_places` | +| Neither set | Locale/currency default (e.g. 2 for USD) | + +```html +{% raw %} +{% load report %} + + +{% render_currency order.total_price currency='USD' %} + + + +{% render_currency order.total_price currency='USD' decimal_places=3 %} + + + +{% render_currency order.total_price currency='USD' decimal_places=2 max_decimal_places=4 %} + + +{% endraw %} +``` + +#### Locale and Symbol Rendering + +The locale controls how the currency symbol and separators are rendered. For example, `USD 1234.56` with various locales: + +| Locale | Output | +| --- | --- | +| `en-us` | `$1,234.56` | +| `en-gb` | `US$1,234.56` | +| `en-au` | `USD1,234.56` | +| `de-de` | `1.234,56 $` | + +The locale is resolved in the following priority order: + +1. **Explicit `locale=` argument** — highest priority, always wins +2. **Server `LANGUAGE_CODE`** — fallback + +#### Leading Digits + +The `leading` argument specifies the minimum number of digits to render before the decimal point (zero-padded). This works identically to `leading` in [`format_number`](#format_number): + +```html +{% raw %} +{% load report %} + + +{% render_currency order.total_price currency='USD' %} + + + +{% render_currency order.total_price currency='USD' leading=4 %} + + +{% endraw %} +``` + +#### Custom Format Strings + +The `fmt` argument accepts a [Unicode number pattern](https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns) string (same syntax as [`format_number`](#custom-format-strings)). **When `fmt` is provided, it takes complete priority over `decimal_places`, `max_decimal_places`, and `leading`** — those arguments are ignored. + +The `locale`, `currency`, `multiplier`, and `include_symbol` arguments are still applied when `fmt` is set. + +To include the currency symbol in a `fmt` pattern, use the `¤` placeholder. Without it, no symbol appears regardless of `include_symbol`. + +| Pattern | Example output (en-us, USD) | +| --- | --- | +| `#,##0.00` | `1,234.56` (no symbol) | +| `¤#,##0.00` | `$1,234.56` | +| `¤#,##0.0000` | `$1,234.5600` | +| `¤ #,##0.00` | `$ 1,234.56` | + +```html +{% raw %} +{% load report %} + + +{% render_currency order.total_price currency='USD' fmt='#,##0.00' %} + + + +{% render_currency order.total_price currency='USD' fmt='¤#,##0.0000' locale='en-us' %} + + + +{% render_currency order.total_price currency='USD' fmt='#,##0.00' locale='de-de' %} + + + +{% render_currency order.total_price currency='USD' fmt='0.0000' decimal_places=2 %} + + +{% endraw %} +``` #### Example @@ -392,8 +626,12 @@ The helper function `render_currency` allows for simple rendering of currency da {% endfor %} + Total Price: {% render_currency order.total_price currency='NZD' decimal_places=2 %} + +Total Price: {% render_currency order.total_price currency='USD' locale='en-us' %} + {% endraw %} ``` diff --git a/src/backend/InvenTree/InvenTree/format.py b/src/backend/InvenTree/InvenTree/format.py index 3ee674b216..848c7e07ae 100644 --- a/src/backend/InvenTree/InvenTree/format.py +++ b/src/backend/InvenTree/InvenTree/format.py @@ -2,16 +2,9 @@ import re import string -from typing import Optional -from django.conf import settings -from django.utils import translation from django.utils.translation import gettext_lazy as _ -from babel import Locale -from babel.numbers import parse_pattern -from djmoney.money import Money - def parse_format_string(fmt_string: str) -> dict: """Extract formatting information from the provided format string. @@ -180,43 +173,3 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str: # And return the value we are interested in # Note: This will raise an IndexError if the named group was not matched return result.group(name) - - -def format_money( - money: Money, - decimal_places: Optional[int] = None, - fmt: Optional[str] = None, - include_symbol: bool = True, -) -> str: - """Format money object according to the currently set local. - - Args: - money (Money): The money object to format - decimal_places (int): Number of decimal places to use - fmt (str): Format pattern according LDML / the babel format pattern syntax (https://babel.pocoo.org/en/latest/numbers.html) - include_symbol (bool): Whether to include the currency symbol in the formatted output - - Returns: - str: The formatted string - - Raises: - ValueError: format string is incorrectly specified - """ - language = (None) or settings.LANGUAGE_CODE - locale = Locale.parse(translation.to_locale(language)) - if fmt: - pattern = parse_pattern(fmt) - else: - pattern = locale.currency_formats['standard'] - if decimal_places is not None: - pattern.frac_prec = (decimal_places, decimal_places) - - result = pattern.apply( - money.amount, - locale, - currency=money.currency.code if include_symbol else '', - currency_digits=decimal_places is None, - decimal_quantization=decimal_places is not None, - ) - - return result diff --git a/src/backend/InvenTree/InvenTree/helpers_model.py b/src/backend/InvenTree/InvenTree/helpers_model.py index ccd1cfd963..925046aef1 100644 --- a/src/backend/InvenTree/InvenTree/helpers_model.py +++ b/src/backend/InvenTree/InvenTree/helpers_model.py @@ -3,12 +3,10 @@ import io import ipaddress import socket -from decimal import Decimal from typing import Optional, cast from urllib.parse import urljoin, urlparse from django.conf import settings -from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.db.utils import OperationalError, ProgrammingError from django.utils.translation import gettext_lazy as _ @@ -16,8 +14,6 @@ from django.utils.translation import gettext_lazy as _ import requests import requests.exceptions import structlog -from djmoney.contrib.exchange.models import convert_money -from djmoney.money import Money from PIL import Image from common.notifications import ( @@ -31,7 +27,6 @@ from InvenTree.cache import ( get_session_cache, set_session_cache, ) -from InvenTree.format import format_money from InvenTree.ready import ignore_ready_warning logger = structlog.get_logger('inventree') @@ -252,85 +247,6 @@ def download_image_from_url( return img -def render_currency( - money: Money, - decimal_places: Optional[int] = None, - currency: Optional[str] = None, - multiplier: Optional[Decimal] = None, - min_decimal_places: Optional[int] = None, - max_decimal_places: Optional[int] = None, - include_symbol: bool = True, -): - """Render a currency / Money object to a formatted string (e.g. for reports). - - Arguments: - money: The Money instance to be rendered - decimal_places: The number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting. - currency: Optionally convert to the specified currency - multiplier: An optional multiplier to apply to the money amount before rendering - min_decimal_places: The minimum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES_MIN setting. - max_decimal_places: The maximum number of decimal places to render to. If unspecified, uses the PRICING_DECIMAL_PLACES setting. - include_symbol: If True, include the currency symbol in the output - """ - if money in [None, '']: - return '-' - - if type(money) is not Money: - # Try to convert to a Money object - try: - money = Money( - Decimal(str(money)), - currency or get_global_setting('INVENTREE_DEFAULT_CURRENCY'), - ) - except Exception: - raise ValidationError( - f"render_currency: {_('Invalid money value')}: '{money}' ({type(money).__name__})" - ) - - if currency is not None: - # Attempt to convert to the provided currency - # If cannot be done, leave the original - try: - money = convert_money(money, currency) - except Exception: - pass - - if multiplier is not None: - try: - money *= Decimal(str(multiplier).strip()) - except Exception: - raise ValidationError( - f"render_currency: {_('Invalid multiplier value')}: '{multiplier}' ({type(multiplier).__name__})" - ) - - if min_decimal_places is None or not isinstance(min_decimal_places, (int, float)): - min_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0) - - if max_decimal_places is None or not isinstance(max_decimal_places, (int, float)): - max_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES', 6) - - value = Decimal(str(money.amount)).normalize() - value = str(value) - - if decimal_places is not None and isinstance(decimal_places, (int, float)): - # Decimal place count is provided, use it - pass - elif '.' in value: - # If the value has a decimal point, use the number of decimal places in the value - decimal_places = len(value.split('.')[-1]) - else: - # No decimal point, use 2 as a default - decimal_places = 2 - - # Clip the decimal places to the specified range - decimal_places = max(decimal_places, min_decimal_places) - decimal_places = min(decimal_places, max_decimal_places) - - return format_money( - money, decimal_places=decimal_places, include_symbol=include_symbol - ) - - @ignore_ready_warning def getModelsWithMixin(mixin_class) -> list: """Return a list of database models that inherit from the given mixin class. diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index 38062926d5..ea2f6d4171 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -608,35 +608,6 @@ class FormatTest(TestCase): with self.assertRaises(ValueError): InvenTree.format.extract_named_group('test', 'PO-ABC-xyz', 'PO-###-{test}') - def test_currency_formatting(self): - """Test that currency formatting works correctly for multiple currencies.""" - test_data = ( - (Money(3651.285718, 'USD'), 4, True, '$3,651.2857'), - (Money(487587.849178, 'CAD'), 5, True, 'CA$487,587.84918'), - (Money(0.348102, 'EUR'), 1, False, '0.3'), - (Money(0.916530, 'GBP'), 1, True, '£0.9'), - (Money(61.031024, 'JPY'), 3, False, '61.031'), - (Money(49609.694602, 'JPY'), 1, True, '¥49,609.7'), - (Money(155565.264777, 'AUD'), 2, False, '155,565.26'), - (Money(0.820437, 'CNY'), 4, True, 'CN¥0.8204'), - (Money(7587.849178, 'EUR'), 0, True, '€7,588'), - (Money(0.348102, 'GBP'), 3, False, '0.348'), - (Money(0.652923, 'CHF'), 0, True, 'CHF1'), - (Money(0.820437, 'CNY'), 1, True, 'CN¥0.8'), - (Money(98789.5295680, 'CHF'), 0, False, '98,790'), - (Money(0.585787, 'USD'), 1, True, '$0.6'), - (Money(0.690541, 'CAD'), 3, True, 'CA$0.691'), - (Money(427.814104, 'AUD'), 5, True, 'A$427.81410'), - ) - - with self.settings(LANGUAGE_CODE='en-us'): - for value, decimal_places, include_symbol, expected_result in test_data: - result = InvenTree.format.format_money( - value, decimal_places=decimal_places, include_symbol=include_symbol - ) - - self.assertEqual(result, expected_result) - class TestHelpers(TestCase): """Tests for InvenTree helper functions.""" diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index 226a42b7d2..d1d9e63ebf 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -4,6 +4,7 @@ import re from django.core.exceptions import SuspiciousFileOperation, ValidationError from django.core.files.storage import default_storage +from django.utils import translation from django.utils.translation import gettext_lazy as _ import common.icons @@ -171,3 +172,17 @@ def validate_variable_string(value: str): """The passed value must be a valid variable identifier string.""" if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value): raise ValidationError(_('Value must be a valid variable identifier')) + + +def validate_locale(value: str): + """Validate that the provided value is a valid locale string.""" + from babel import Locale + from babel.core import UnknownLocaleError + + if not value: + return + + try: + Locale.parse(translation.to_locale(value)) + except (UnknownLocaleError, ValueError) as e: + raise ValidationError(f"Invalid locale value: '{value}' - {e}") diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index be28eef151..5ec71e1891 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -1,6 +1,7 @@ """Custom template tags for report generation.""" import base64 +import copy import logging import mimetypes from datetime import date, datetime @@ -11,14 +12,22 @@ 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 from django.db.models import Model from django.db.models.query import QuerySet +from django.utils import translation from django.utils.safestring import SafeString, mark_safe from django.utils.translation import gettext_lazy as _ +from babel import Locale +from babel.core import UnknownLocaleError +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 @@ -40,6 +49,22 @@ register = template.Library() logger = logging.getLogger('inventree') +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}") + + @register.simple_tag() def order_queryset(queryset: QuerySet, *args) -> QuerySet: """Order a database queryset based on the provided arguments. @@ -772,9 +797,98 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any: @register.simple_tag -def render_currency(money, **kwargs): - """Render a currency / Money object.""" - return InvenTree.helpers_model.render_currency(money, **kwargs) +def render_currency( + money: Money | str | int | float | Decimal, + decimal_places: Optional[int] = None, + currency: Optional[str] = None, + multiplier: Optional[Decimal] = None, + max_decimal_places: Optional[int] = None, + include_symbol: bool = True, + leading: Optional[int] = None, + fmt: Optional[str] = None, + locale: Optional[str] = None, + **kwargs, +) -> str: + """Render a currency / Money object to a formatted string. + + 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 + leading: Minimum number of leading digits to render before the decimal point (default = 1) + fmt: Optional Babel number pattern string. When provided, takes priority over all other formatting options. + locale: Optional locale override (e.g. 'en-us', 'de-de'). Defaults to server LANGUAGE_CODE. + """ + if money in [None, '']: + return '-' + + # If the supplied value is *not* a Money instance, attempt to convert it into one + if not isinstance(money, Money): + try: + money = Money( + Decimal(str(money)), + currency or get_global_setting('INVENTREE_DEFAULT_CURRENCY'), + ) + except Exception: + raise ValidationError(f'render_currency: invalid money value - {money!r}') + + 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( + f'render_currency: invalid multiplier value - {multiplier!r}' + ) + + 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, + ) + + pattern = copy.copy(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) + + 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)) + + if leading is not None: + try: + leading = int(leading) or 0 + except (ValueError, TypeError): + leading = 0 + if leading > 0: + min_int, max_int = pattern.int_prec + pattern.int_prec = (max(leading, min_int), max(leading, max_int)) + + 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 @@ -871,82 +985,109 @@ def render_html_text(text: str, **kwargs): @register.simple_tag def format_number( number: int | float | Decimal, - decimal_places: Optional[int] = None, multiplier: Optional[int | float | Decimal] = None, integer: bool = False, - leading: int = 0, - separator: Optional[str] = None, + separator: bool = False, + leading: Optional[int] = None, + decimal_places: Optional[int] = None, + max_decimal_places: Optional[int] = None, + fmt: Optional[str] = None, + locale: Optional[str] = None, + **kwargs, ) -> str: """Render a number with optional formatting options. Arguments: number: The number to be formatted - decimal_places: Number of decimal places to render 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: Boolean, whether to include a thousands separator + leading: Minimum number of leading digits to render (default = 1) + decimal_places: Number of decimal places to render (default = 0) + max_decimal_places: Maximum number of decimal places to render (default = 0) + separator: + fmt: Optional format string for the number - if provided, takes priority over 'decimal_places' and 'leading' + locale: Optional locale override (e.g. 'en-us', 'de-de'). When set, babel controls decimal and thousands separators. """ check_nulls('format_number', number) + # Check that the provided number is valid try: number = Decimal(str(number).strip()) except Exception: # If the number cannot be converted to a Decimal, just return the original value return str(number) + number = float(number) + if multiplier is not None: - number *= Decimal(str(multiplier).strip()) + number *= multiplier if integer: - # Convert to integer - number = Decimal(int(number)) + number = int(number) - # Normalize the number (remove trailing zeroes) - number = number.normalize() + # Construct a formatting string for the number, based on the provided options + if not fmt: + fmt = '###,###,###,###,##0' # Default format string - this will be modified based on the provided options - if decimal_places is not None: - try: - decimal_places = int(decimal_places) - number = round(number, decimal_places) - except ValueError: - pass + # The 'leading' option specifies the minimum number of leading digits to render (not including decimal places) + if leading is not None: + try: + leading = int(leading) or 0 + except (ValueError, TypeError): + leading = 0 - # 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() + if leading > 1: + fmt = fmt[::-1].replace('#', '0', (leading - 1))[::-1] + + if not bool(separator): + fmt = fmt.replace(',', '') + + if decimal_places is not None or max_decimal_places is not None: + # Account for decimal places, if provided + + try: + decimal_places = int(decimal_places) or 0 + except (ValueError, TypeError): + decimal_places = 0 + + try: + max_decimal_places = int(max_decimal_places) or 0 + except (ValueError, TypeError): + max_decimal_places = 0 + + fmt += '.' + '0' * decimal_places + + if max_decimal_places > decimal_places: + fmt += '#' * (max_decimal_places - decimal_places) + elif not integer: + # No decimal places specified, allow any number of decimal places (up to the precision of the Decimal) + fmt += '.####################' + + babel_locale = get_locale(locale) + + return babel_format_decimal( + number, format=fmt, locale=babel_locale, numbering_system='latn' ) - if separator: - value = f'{value:,}' - value = value.replace(',', separator) - else: - value = f'{value}' - - if leading is not None: - try: - leading = int(leading) - value = '0' * leading + value - except ValueError: - pass - - return value - @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, + date_format: str = 'medium', + **kwargs, ): """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 and date_format. + locale: Optional locale override (e.g. 'en-us', 'de-de'). Used for locale-aware formatting when no fmt is given. + date_format: Babel date format style. One of 'full', 'long', 'medium' (default), 'short'. """ check_nulls('format_datetime', dt) @@ -954,18 +1095,27 @@ def format_datetime( if fmt: return dt.strftime(fmt) - else: - return dt.isoformat() + + return babel_format_datetime(dt, format=date_format, locale=get_locale(locale)) @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, + date_format: str = 'medium', + **kwargs, +): """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 and date_format. + locale: Optional locale override (e.g. 'en-us', 'de-de'). Used for locale-aware formatting when no fmt is given. + date_format: Babel date format style. One of 'full', 'long', 'medium' (default), 'short'. """ check_nulls('format_date', dt) @@ -976,8 +1126,8 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N if fmt: return dt.strftime(fmt) - else: - return dt.isoformat() + + return babel_format_date(dt, format=date_format, 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 cf7c20ec4d..b5bf18f806 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 @@ -359,26 +360,60 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): for x in ['10.000000', ' 10 ', 10.000000, 10]: self.assertEqual(fn(x), '10') + # Test with various formatting options self.assertEqual(fn(1234), '1234') self.assertEqual(fn(1234.5678, decimal_places=0), '1235') + self.assertEqual(fn(1234.5678, decimal_places=0, separator=True), '1,235') 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') self.assertEqual( - fn(9988776655.4321, integer=True, separator=' '), '9 988 776 655' + fn(1234.5678, decimal_places=2, max_decimal_places=10), '1234.5678' ) + self.assertEqual(fn(1234.5678, decimal_places=3), '1234.568') + self.assertEqual(fn(-9999.5678, decimal_places=2, locale='fr-fr'), '-9999,57') + self.assertEqual( + fn(-9999.5678, decimal_places=2, locale='fr-fr', separator=True), + '-9\u202f999,57', + ) + self.assertEqual( + fn(9988776655.4321, integer=True, locale='de-de', separator=True), + '9.988.776.655', + ) + + # Test with 'leading' option + self.assertEqual(fn(5, leading=3), '005') + self.assertEqual(fn(123, leading=5), '00123') + self.assertEqual(fn(1234, leading=2, decimal_places=4), '1234.0000') + + # Test with custom 'fmt' format string (takes priority over decimal_places / separator) + self.assertEqual(fn(1234.5678, fmt='0.00'), '1234.57') + self.assertEqual(fn(1234.5678, fmt='#,##0.00'), '1,234.57') + self.assertEqual(fn(1234.5678, fmt='0.00', locale='de-de'), '1234,57') + self.assertEqual(fn(1234.5678, fmt='#,##0.00', locale='de-de'), '1.234,57') + # fmt bypasses decimal_places and separator options + self.assertEqual( + fn(1234.5678, fmt='0.00', decimal_places=4, separator=True), '1234.57' + ) + # integer conversion still applies before fmt is used + self.assertEqual( + fn(9988776655.4321, fmt='#,##0', integer=True), '9,988,776,655' + ) + # multiplier is applied before fmt is used + self.assertEqual(fn(100, fmt='0.00', multiplier=1.5), '150.00') # Test with multiplier self.assertEqual(fn(1000, multiplier=1.5), '1500') # Failure cases self.assertEqual(fn('abc'), 'abc') - self.assertEqual(fn(1234.456, decimal_places='a'), '1234.456') + self.assertEqual(fn(1234.456, decimal_places='a'), '1234') + self.assertEqual( + fn(1234.456, decimal_places='a', separator=True, locale='en-au'), '1,234' + ) self.assertEqual(fn(1234.456, leading='a'), '1234.456') @override_settings(TIME_ZONE='America/New_York') - def test_date_tags(self): + def test_datetime_tags(self): """Test for date formatting tags. - Source timezone is Australia/Sydney @@ -394,18 +429,21 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): tzinfo=ZoneInfo('Australia/Sydney'), ) - # Format a set of tests: timezone, format, expected + # Format a set of tests: timezone, format, locale, expected tests = [ - (None, None, '2024-03-12T21:30:00-04:00'), - (None, '%d-%m-%y', '12-03-24'), - ('UTC', None, '2024-03-13T01:30:00+00:00'), - ('UTC', '%d-%B-%Y', '13-March-2024'), - ('Europe/Amsterdam', None, '2024-03-13T02:30:00+01:00'), - ('Europe/Amsterdam', '%y-%m-%d %H:%M', '24-03-13 02:30'), + (None, None, 'en-us', 'Mar 12, 2024, 9:30:00 PM'), # noqa: RUF001 + (None, '%d-%m-%y', 'en-us', '12-03-24'), + ('UTC', None, 'en-us', 'Mar 13, 2024, 1:30:00 AM'), # noqa: RUF001 + ('UTC', '%d-%B-%Y', 'en-us', '13-March-2024'), + ('Europe/Amsterdam', None, 'de-de', '13.03.2024, 02:30:00'), + ('Europe/Amsterdam', '%y-%m-%d %H:%M', 'de-de', '24-03-13 02:30'), ] - for tz, fmt, expected in tests: - result = report_tags.format_datetime(time, tz, fmt) + for tz, fmt, locale, expected in tests: + print(tz, fmt, locale, expected) + result = report_tags.format_datetime( + time, timezone=tz, fmt=fmt, locale=locale + ) self.assertEqual(result, expected) def test_icon(self): @@ -519,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 @@ -544,9 +585,101 @@ 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 + self.assertEqual(report_tags.render_currency(m, locale='en-us'), '$1,234.56') + 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' + ) + + # Test leading digits + m_small = Money(1.23, 'USD') + self.assertEqual( + report_tags.render_currency(m_small, leading=4, locale='en-us'), '$0,001.23' + ) + # leading=1 is the default — no change + self.assertEqual( + report_tags.render_currency(m_small, leading=1, locale='en-us'), '$1.23' + ) + # invalid leading falls back gracefully + self.assertEqual( + report_tags.render_currency(m_small, leading='x', locale='en-us'), '$1.23' + ) + # fmt takes priority over leading + self.assertEqual( + report_tags.render_currency(m_small, leading=6, fmt='#,##0.00'), '1.23' + ) + + # Test include_symbol + # Default (True) — symbol present + self.assertEqual(report_tags.render_currency(m, locale='en-us'), '$1,234.56') + # Explicit False — symbol suppressed + self.assertEqual( + report_tags.render_currency(m, include_symbol=False, locale='en-us'), + '1,234.56', + ) + # include_symbol=False with fmt containing ¤ — ¤ renders as empty string + self.assertEqual( + report_tags.render_currency( + m, include_symbol=False, fmt='¤#,##0.00', locale='en-us' + ), + '1,234.56', + ) + # include_symbol=False with fmt lacking ¤ — no symbol either way + self.assertEqual( + report_tags.render_currency( + m, include_symbol=False, fmt='#,##0.00', locale='en-us' + ), + '1,234.56', + ) + + def test_render_currency_locale_override(self): + """Explicit locale= kwarg takes priority over global setting and system locale.""" + m = Money(1234.56, 'USD') + + # Explicit locale overrides system LANGUAGE_CODE + with override_settings(LANGUAGE_CODE='en-au'): + self.assertEqual( + report_tags.render_currency(m, locale='en-us'), '$1,234.56' + ) + self.assertEqual( + report_tags.render_currency(m, locale='en-gb'), 'US$1,234.56' + ) + + # Invalid locale raises ValidationError regardless of other settings + with self.assertRaises(ValidationError): + report_tags.render_currency(m, locale='xx-zz') + + def test_render_currency_system_locale(self): + """render_currency uses system LANGUAGE_CODE when no explicit locale= is passed.""" + m = Money(1234.56, 'USD') + + with override_settings(LANGUAGE_CODE='en-us'): + self.assertEqual(report_tags.render_currency(m), '$1,234.56') + + with override_settings(LANGUAGE_CODE='en-gb'): + self.assertEqual(report_tags.render_currency(m), 'US$1,234.56') + + with override_settings(LANGUAGE_CODE='en-au'): + self.assertEqual(report_tags.render_currency(m), 'USD1,234.56') + def test_create_currency(self): """Test the create_currency template tag.""" m = report_tags.create_currency(1000, 'USD') @@ -618,14 +751,94 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): def test_format_date(self): """Test the format_date template tag.""" - # Test with a valid date - date = timezone.datetime(year=2024, month=3, day=13) - self.assertEqual(report_tags.format_date(date), '2024-03-13') - self.assertEqual(report_tags.format_date(date, fmt='%d-%m-%y'), '13-03-24') + dt = timezone.datetime(year=2024, month=3, day=13) + self.assertEqual(report_tags.format_date(dt, locale='de-de'), '13.03.2024') + self.assertEqual(report_tags.format_date(dt, locale='en-us'), 'Mar 13, 2024') + self.assertEqual(report_tags.format_date(dt, locale='en-au'), '13 Mar 2024') + self.assertEqual(report_tags.format_date(dt, locale='fr-fr'), '13 mars 2024') + self.assertEqual(report_tags.format_date(dt, fmt='%d-%m-%y'), '13-03-24') # Test with an invalid date self.assertEqual(report_tags.format_date('abc'), 'abc') - self.assertEqual(report_tags.format_date(date, fmt='a'), 'a') + self.assertEqual(report_tags.format_date(dt, fmt='a'), 'a') + + # Explicit fmt always wins over locale + self.assertEqual( + report_tags.format_date(dt, fmt='%Y-%m-%d', locale='de-de'), '2024-03-13' + ) + + # Falls back to LANGUAGE_CODE when no locale= arg + with override_settings(LANGUAGE_CODE='en-us'): + self.assertEqual(report_tags.format_date(dt), 'Mar 13, 2024') + + # date_format controls the Babel style + self.assertEqual( + report_tags.format_date(dt, locale='en-us', date_format='short'), '3/13/24' + ) + self.assertEqual( + report_tags.format_date(dt, locale='en-us', date_format='long'), + 'March 13, 2024', + ) + self.assertEqual( + report_tags.format_date(dt, locale='en-us', date_format='full'), + 'Wednesday, March 13, 2024', + ) + + # fmt= wins over date_format= + self.assertEqual( + report_tags.format_date(dt, fmt='%Y', locale='en-us', date_format='full'), + '2024', + ) + + # Invalid locale raises ValidationError + with self.assertRaises(ValidationError): + report_tags.format_date(dt, locale='xx-zz') + + def test_format_datetime(self): + """Test that format_datetime renders locale-aware output.""" + from zoneinfo import ZoneInfo + + dt = timezone.datetime(2026, 6, 19, 15, 30, 0, tzinfo=ZoneInfo('UTC')) + + self.assertEqual( + report_tags.format_datetime(dt, locale='en-us'), + 'Jun 19, 2026, 3:30:00 PM', # noqa: RUF001 + ) + self.assertEqual( + report_tags.format_datetime(dt, locale='de-de'), '19.06.2026, 15:30:00' + ) + + # Explicit fmt still wins + self.assertEqual( + report_tags.format_datetime(dt, fmt='%Y-%m-%d', locale='de-de'), + '2026-06-19', + ) + + # Falls back to LANGUAGE_CODE when no locale= arg + with override_settings(LANGUAGE_CODE='de-de'): + self.assertEqual(report_tags.format_datetime(dt), '19.06.2026, 15:30:00') + + # date_format controls the Babel style + self.assertEqual( + report_tags.format_datetime(dt, locale='de-de', date_format='short'), + '19.06.26, 15:30', + ) + self.assertEqual( + report_tags.format_datetime(dt, locale='de-de', date_format='long'), + '19. Juni 2026, 15:30:00 UTC', # codespell:ignore "Juni" + ) + + # fmt= wins over date_format= + self.assertEqual( + report_tags.format_datetime( + dt, fmt='%H:%M', locale='en-us', date_format='full' + ), + '15:30', + ) + + # Invalid locale raises ValidationError + with self.assertRaises(ValidationError): + report_tags.format_datetime(dt, locale='xx-zz') class BarcodeTagTest(TestCase):