2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

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"
This commit is contained in:
Oliver
2026-06-20 11:00:12 +10:00
committed by GitHub
parent 8a092b4d1d
commit ca16e6ec0a
9 changed files with 711 additions and 248 deletions
+1
View File
@@ -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.
+6
View File
@@ -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.
+252 -14
View File
@@ -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 %}
<!-- output: 0003.14159 -->
<!-- Basic usage: strip trailing zeros -->
{% format_number 3.14159265359 decimal_places=5 %}
<!-- output: 3.14159 -->
<!-- Leading zeros with 'leading' option -->
{% format_number 3.14159265359 decimal_places=5 leading=3 %}
<!-- output: 003.14159 -->
<!-- Round to integer -->
{% format_number 3.14159265359 integer=True %}
<!-- output: 3 -->
<!-- Thousands separator -->
{% format_number 9988776.5 decimal_places=2 separator=True %}
<!-- output: 9,988,776.50 -->
<!-- Locale-aware formatting: decimal comma, dot thousands separator -->
{% format_number 9988776.5 decimal_places=2 separator=True locale='de-de' %}
<!-- output: 9.988.776,50 -->
<!-- Scale a value with a multiplier before formatting -->
{% format_number 0.175 multiplier=100 decimal_places=1 %}
<!-- output: 17.5 -->
<!-- Allow up to N significant decimal places, but suppress trailing zeros -->
{% format_number 1234.5 decimal_places=2 max_decimal_places=6 %}
<!-- output: 1234.5 -->
{% 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 %}
<!-- Two decimal places, no grouping -->
{% format_number 1234.5678 fmt='0.00' %}
<!-- output: 1234.57 -->
<!-- Two decimal places with thousands separator -->
{% format_number 1234.5678 fmt='#,##0.00' %}
<!-- output: 1,234.57 -->
<!-- Same pattern, German locale: dot thousands, comma decimal -->
{% format_number 1234.5678 fmt='#,##0.00' locale='de-de' %}
<!-- output: 1.234,57 -->
<!-- Integer with thousands separator, large number -->
{% format_number 9988776655.4321 fmt='#,##0' integer=True %}
<!-- output: 9,988,776,655 -->
{% 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 %}
<!-- Default: medium style, locale from LANGUAGE_CODE -->
{% format_date my_date %}
<!-- output (en-us): Jan 12, 2025 -->
<!-- Explicit strftime format string — locale and date_format are ignored -->
{% format_date my_date fmt="%d/%m/%Y" %}
<!-- output: 12/01/2025 -->
<!-- Locale-aware, default medium style -->
{% format_date my_date locale='en-us' %}
<!-- output: Jan 12, 2025 -->
<!-- Short style -->
{% format_date my_date locale='en-us' date_format='short' %}
<!-- output: 1/12/25 -->
<!-- Full style -->
{% format_date my_date locale='en-us' date_format='full' %}
<!-- output: Sunday, January 12, 2025 -->
{% 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" %}
<!-- Default: medium style, locale from LANGUAGE_CODE -->
{% format_datetime my_datetime %}
<!-- output (en-us): Jan 12, 2025, 2:30:00 PM -->
<!-- Explicit strftime format — locale and date_format are ignored -->
{% format_datetime my_datetime fmt="%d-%m-%Y %H:%M" %}
<!-- output: 12-01-2025 14:30 -->
<!-- Locale-aware, default medium style -->
{% format_datetime my_datetime locale='en-us' %}
<!-- output: Jan 12, 2025, 2:30:00 PM -->
<!-- Short style -->
{% format_datetime my_datetime locale='de-de' date_format='short' %}
<!-- output: 12.01.25, 14:30 -->
<!-- Convert to a specific timezone before formatting -->
{% 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 %}
<!-- locale default for USD: 2 decimal places -->
{% render_currency order.total_price currency='USD' %}
<!-- output: $1,234.56 -->
<!-- force 3 decimal places -->
{% render_currency order.total_price currency='USD' decimal_places=3 %}
<!-- output: $1,234.560 -->
<!-- at least 2, up to 4 — trailing zeros beyond the value are suppressed -->
{% render_currency order.total_price currency='USD' decimal_places=2 max_decimal_places=4 %}
<!-- output: $1,234.5600 or $1,234.56 depending on the value -->
{% 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 %}
<!-- default: no padding -->
{% render_currency order.total_price currency='USD' %}
<!-- output: $1.23 -->
<!-- force at least 4 integer digits -->
{% render_currency order.total_price currency='USD' leading=4 %}
<!-- output: $0,001.23 -->
{% 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 %}
<!-- No symbol (no ¤ in pattern) -->
{% render_currency order.total_price currency='USD' fmt='#,##0.00' %}
<!-- output: 1,234.56 -->
<!-- Symbol via ¤ placeholder -->
{% render_currency order.total_price currency='USD' fmt='¤#,##0.0000' locale='en-us' %}
<!-- output: $1,234.5600 -->
<!-- fmt + locale: de-de separators -->
{% render_currency order.total_price currency='USD' fmt='#,##0.00' locale='de-de' %}
<!-- output: 1.234,56 -->
<!-- fmt takes priority — decimal_places=2 is ignored -->
{% render_currency order.total_price currency='USD' fmt='0.0000' decimal_places=2 %}
<!-- output: 1234.5600 -->
{% endraw %}
```
#### Example
@@ -392,8 +626,12 @@ The helper function `render_currency` allows for simple rendering of currency da
{% endfor %}
</ul>
<!-- Force 2 decimal places, convert to NZD -->
Total Price: {% render_currency order.total_price currency='NZD' decimal_places=2 %}
<!-- US-style symbol, regardless of server locale -->
Total Price: {% render_currency order.total_price currency='USD' locale='en-us' %}
{% endraw %}
```
-47
View File
@@ -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
@@ -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.
-29
View File
@@ -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."""
@@ -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}")
@@ -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()
if decimal_places is not None:
try:
decimal_places = int(decimal_places)
number = round(number, decimal_places)
except ValueError:
pass
# 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 separator:
value = f'{value:,}'
value = value.replace(',', separator)
else:
value = f'{value}'
# 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
# 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)
value = '0' * leading + value
except ValueError:
pass
leading = int(leading) or 0
except (ValueError, TypeError):
leading = 0
return value
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'
)
@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()
+237 -24
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
@@ -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:00PM'), # noqa: RUF001
(None, '%d-%m-%y', 'en-us', '12-03-24'),
('UTC', None, 'en-us', 'Mar 13, 2024, 1:30:00AM'), # 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:00PM', # 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):