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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user