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
|
### 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
|
- [#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
|
- [#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.
|
- [#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
|
## Pricing Settings
|
||||||
|
|
||||||
Refer to the [global settings](../settings/global.md#pricing-and-currency) documentation for more information on available currency 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_docstring_description: false
|
||||||
show_source: False
|
show_source: False
|
||||||
|
|
||||||
#### Example
|
#### Examples
|
||||||
|
|
||||||
```html
|
```html
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{% load report %}
|
{% 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 %}
|
{% format_number 3.14159265359 integer=True %}
|
||||||
<!-- output: 3 -->
|
<!-- 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 %}
|
{% 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:
|
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
|
### format_date
|
||||||
|
|
||||||
::: report.templatetags.report.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_docstring_description: false
|
||||||
show_source: 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
|
### format_datetime
|
||||||
|
|
||||||
::: report.templatetags.report.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_docstring_description: false
|
||||||
show_source: False
|
show_source: False
|
||||||
|
|
||||||
### Date Formatting
|
#### Examples
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
{% raw %}
|
{% raw %}
|
||||||
{% load report %}
|
{% 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 %}
|
{% 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:
|
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:
|
options:
|
||||||
show_docstring_description: false
|
show_docstring_description: false
|
||||||
show_source: 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
|
#### Example
|
||||||
|
|
||||||
@@ -392,8 +626,12 @@ The helper function `render_currency` allows for simple rendering of currency da
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Force 2 decimal places, convert to NZD -->
|
||||||
Total Price: {% render_currency order.total_price currency='NZD' decimal_places=2 %}
|
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 %}
|
{% endraw %}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,9 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import string
|
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 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:
|
def parse_format_string(fmt_string: str) -> dict:
|
||||||
"""Extract formatting information from the provided format string.
|
"""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
|
# And return the value we are interested in
|
||||||
# Note: This will raise an IndexError if the named group was not matched
|
# Note: This will raise an IndexError if the named group was not matched
|
||||||
return result.group(name)
|
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 io
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Optional, cast
|
from typing import Optional, cast
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -16,8 +14,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
import requests
|
import requests
|
||||||
import requests.exceptions
|
import requests.exceptions
|
||||||
import structlog
|
import structlog
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
|
||||||
from djmoney.money import Money
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from common.notifications import (
|
from common.notifications import (
|
||||||
@@ -31,7 +27,6 @@ from InvenTree.cache import (
|
|||||||
get_session_cache,
|
get_session_cache,
|
||||||
set_session_cache,
|
set_session_cache,
|
||||||
)
|
)
|
||||||
from InvenTree.format import format_money
|
|
||||||
from InvenTree.ready import ignore_ready_warning
|
from InvenTree.ready import ignore_ready_warning
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
@@ -252,85 +247,6 @@ def download_image_from_url(
|
|||||||
return img
|
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
|
@ignore_ready_warning
|
||||||
def getModelsWithMixin(mixin_class) -> list:
|
def getModelsWithMixin(mixin_class) -> list:
|
||||||
"""Return a list of database models that inherit from the given mixin class.
|
"""Return a list of database models that inherit from the given mixin class.
|
||||||
|
|||||||
@@ -608,35 +608,6 @@ class FormatTest(TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
InvenTree.format.extract_named_group('test', 'PO-ABC-xyz', 'PO-###-{test}')
|
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):
|
class TestHelpers(TestCase):
|
||||||
"""Tests for InvenTree helper functions."""
|
"""Tests for InvenTree helper functions."""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import re
|
|||||||
|
|
||||||
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.utils import translation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import common.icons
|
import common.icons
|
||||||
@@ -171,3 +172,17 @@ def validate_variable_string(value: str):
|
|||||||
"""The passed value must be a valid variable identifier string."""
|
"""The passed value must be a valid variable identifier string."""
|
||||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
|
||||||
raise ValidationError(_('Value must be a valid variable identifier'))
|
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."""
|
"""Custom template tags for report generation."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@@ -11,14 +12,22 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
from django.apps.registry import apps
|
from django.apps.registry import apps
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
from django.core.exceptions import SuspiciousFileOperation, ValidationError
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
|
from django.utils import translation
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
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.exceptions import MissingRate
|
||||||
from djmoney.contrib.exchange.models import convert_money
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
from djmoney.money import Money
|
from djmoney.money import Money
|
||||||
@@ -40,6 +49,22 @@ register = template.Library()
|
|||||||
logger = logging.getLogger('inventree')
|
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()
|
@register.simple_tag()
|
||||||
def order_queryset(queryset: QuerySet, *args) -> QuerySet:
|
def order_queryset(queryset: QuerySet, *args) -> QuerySet:
|
||||||
"""Order a database queryset based on the provided arguments.
|
"""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
|
@register.simple_tag
|
||||||
def render_currency(money, **kwargs):
|
def render_currency(
|
||||||
"""Render a currency / Money object."""
|
money: Money | str | int | float | Decimal,
|
||||||
return InvenTree.helpers_model.render_currency(money, **kwargs)
|
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
|
@register.simple_tag
|
||||||
@@ -871,82 +985,109 @@ def render_html_text(text: str, **kwargs):
|
|||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def format_number(
|
def format_number(
|
||||||
number: int | float | Decimal,
|
number: int | float | Decimal,
|
||||||
decimal_places: Optional[int] = None,
|
|
||||||
multiplier: Optional[int | float | Decimal] = None,
|
multiplier: Optional[int | float | Decimal] = None,
|
||||||
integer: bool = False,
|
integer: bool = False,
|
||||||
leading: int = 0,
|
separator: bool = False,
|
||||||
separator: Optional[str] = None,
|
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:
|
) -> str:
|
||||||
"""Render a number with optional formatting options.
|
"""Render a number with optional formatting options.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
number: The number to be formatted
|
number: The number to be formatted
|
||||||
decimal_places: Number of decimal places to render
|
|
||||||
multiplier: Optional multiplier to apply to the number before formatting
|
multiplier: Optional multiplier to apply to the number before formatting
|
||||||
integer: Boolean, whether to render the number as an integer
|
integer: Boolean, whether to render the number as an integer
|
||||||
leading: Number of leading zeros (default = 0)
|
separator: Boolean, whether to include a thousands separator
|
||||||
separator: Character to use as a thousands separator (default = None)
|
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_nulls('format_number', number)
|
||||||
|
|
||||||
|
# Check that the provided number is valid
|
||||||
try:
|
try:
|
||||||
number = Decimal(str(number).strip())
|
number = Decimal(str(number).strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
# If the number cannot be converted to a Decimal, just return the original value
|
# If the number cannot be converted to a Decimal, just return the original value
|
||||||
return str(number)
|
return str(number)
|
||||||
|
|
||||||
|
number = float(number)
|
||||||
|
|
||||||
if multiplier is not None:
|
if multiplier is not None:
|
||||||
number *= Decimal(str(multiplier).strip())
|
number *= multiplier
|
||||||
|
|
||||||
if integer:
|
if integer:
|
||||||
# Convert to integer
|
number = int(number)
|
||||||
number = Decimal(int(number))
|
|
||||||
|
|
||||||
# Normalize the number (remove trailing zeroes)
|
# Construct a formatting string for the number, based on the provided options
|
||||||
number = number.normalize()
|
if not fmt:
|
||||||
|
fmt = '###,###,###,###,##0' # Default format string - this will be modified based on the provided options
|
||||||
|
|
||||||
if decimal_places is not None:
|
# The 'leading' option specifies the minimum number of leading digits to render (not including decimal places)
|
||||||
try:
|
if leading is not None:
|
||||||
decimal_places = int(decimal_places)
|
try:
|
||||||
number = round(number, decimal_places)
|
leading = int(leading) or 0
|
||||||
except ValueError:
|
except (ValueError, TypeError):
|
||||||
pass
|
leading = 0
|
||||||
|
|
||||||
# Re-encode, and normalize again
|
if leading > 1:
|
||||||
# Ensure that the output never uses scientific notation
|
fmt = fmt[::-1].replace('#', '0', (leading - 1))[::-1]
|
||||||
value = Decimal(number)
|
|
||||||
value = (
|
if not bool(separator):
|
||||||
value.quantize(Decimal(1))
|
fmt = fmt.replace(',', '')
|
||||||
if value == value.to_integral()
|
|
||||||
else value.normalize()
|
if decimal_places is not None or max_decimal_places is not None:
|
||||||
|
# Account for decimal places, if provided
|
||||||
|
|
||||||
|
try:
|
||||||
|
decimal_places = int(decimal_places) or 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
decimal_places = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_decimal_places = int(max_decimal_places) or 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
max_decimal_places = 0
|
||||||
|
|
||||||
|
fmt += '.' + '0' * decimal_places
|
||||||
|
|
||||||
|
if max_decimal_places > decimal_places:
|
||||||
|
fmt += '#' * (max_decimal_places - decimal_places)
|
||||||
|
elif not integer:
|
||||||
|
# No decimal places specified, allow any number of decimal places (up to the precision of the Decimal)
|
||||||
|
fmt += '.####################'
|
||||||
|
|
||||||
|
babel_locale = get_locale(locale)
|
||||||
|
|
||||||
|
return babel_format_decimal(
|
||||||
|
number, format=fmt, locale=babel_locale, numbering_system='latn'
|
||||||
)
|
)
|
||||||
|
|
||||||
if separator:
|
|
||||||
value = f'{value:,}'
|
|
||||||
value = value.replace(',', separator)
|
|
||||||
else:
|
|
||||||
value = f'{value}'
|
|
||||||
|
|
||||||
if leading is not None:
|
|
||||||
try:
|
|
||||||
leading = int(leading)
|
|
||||||
value = '0' * leading + value
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def format_datetime(
|
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.
|
"""Format a datetime object for display.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
dt: The datetime object to format
|
dt: The datetime object to format
|
||||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
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)
|
check_nulls('format_datetime', dt)
|
||||||
|
|
||||||
@@ -954,18 +1095,27 @@ def format_datetime(
|
|||||||
|
|
||||||
if fmt:
|
if fmt:
|
||||||
return dt.strftime(fmt)
|
return dt.strftime(fmt)
|
||||||
else:
|
|
||||||
return dt.isoformat()
|
return babel_format_datetime(dt, format=date_format, locale=get_locale(locale))
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@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.
|
"""Format a date object for display.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
dt: The date to format
|
dt: The date to format
|
||||||
timezone: The timezone to use for the date (defaults to the server timezone)
|
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)
|
check_nulls('format_date', dt)
|
||||||
|
|
||||||
@@ -976,8 +1126,8 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N
|
|||||||
|
|
||||||
if fmt:
|
if fmt:
|
||||||
return dt.strftime(fmt)
|
return dt.strftime(fmt)
|
||||||
else:
|
|
||||||
return dt.isoformat()
|
return babel_format_date(dt, format=date_format, locale=get_locale(locale))
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from djmoney.money import Money
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from common.models import InvenTreeSetting, Parameter, ParameterTemplate
|
from common.models import InvenTreeSetting, Parameter, ParameterTemplate
|
||||||
|
from common.settings import set_global_setting
|
||||||
from InvenTree.unit_test import InvenTreeTestCase
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from part.test_api import PartImageTestMixin
|
from part.test_api import PartImageTestMixin
|
||||||
@@ -359,26 +360,60 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
for x in ['10.000000', ' 10 ', 10.000000, 10]:
|
for x in ['10.000000', ' 10 ', 10.000000, 10]:
|
||||||
self.assertEqual(fn(x), '10')
|
self.assertEqual(fn(x), '10')
|
||||||
|
|
||||||
|
# Test with various formatting options
|
||||||
self.assertEqual(fn(1234), '1234')
|
self.assertEqual(fn(1234), '1234')
|
||||||
self.assertEqual(fn(1234.5678, decimal_places=0), '1235')
|
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=1), '1234.6')
|
||||||
self.assertEqual(fn(1234.5678, decimal_places=2), '1234.57')
|
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(
|
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
|
# Test with multiplier
|
||||||
self.assertEqual(fn(1000, multiplier=1.5), '1500')
|
self.assertEqual(fn(1000, multiplier=1.5), '1500')
|
||||||
|
|
||||||
# Failure cases
|
# Failure cases
|
||||||
self.assertEqual(fn('abc'), 'abc')
|
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')
|
self.assertEqual(fn(1234.456, leading='a'), '1234.456')
|
||||||
|
|
||||||
@override_settings(TIME_ZONE='America/New_York')
|
@override_settings(TIME_ZONE='America/New_York')
|
||||||
def test_date_tags(self):
|
def test_datetime_tags(self):
|
||||||
"""Test for date formatting tags.
|
"""Test for date formatting tags.
|
||||||
|
|
||||||
- Source timezone is Australia/Sydney
|
- Source timezone is Australia/Sydney
|
||||||
@@ -394,18 +429,21 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
tzinfo=ZoneInfo('Australia/Sydney'),
|
tzinfo=ZoneInfo('Australia/Sydney'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format a set of tests: timezone, format, expected
|
# Format a set of tests: timezone, format, locale, expected
|
||||||
tests = [
|
tests = [
|
||||||
(None, None, '2024-03-12T21:30:00-04:00'),
|
(None, None, 'en-us', 'Mar 12, 2024, 9:30:00 PM'), # noqa: RUF001
|
||||||
(None, '%d-%m-%y', '12-03-24'),
|
(None, '%d-%m-%y', 'en-us', '12-03-24'),
|
||||||
('UTC', None, '2024-03-13T01:30:00+00:00'),
|
('UTC', None, 'en-us', 'Mar 13, 2024, 1:30:00 AM'), # noqa: RUF001
|
||||||
('UTC', '%d-%B-%Y', '13-March-2024'),
|
('UTC', '%d-%B-%Y', 'en-us', '13-March-2024'),
|
||||||
('Europe/Amsterdam', None, '2024-03-13T02:30:00+01:00'),
|
('Europe/Amsterdam', None, 'de-de', '13.03.2024, 02:30:00'),
|
||||||
('Europe/Amsterdam', '%y-%m-%d %H:%M', '24-03-13 02:30'),
|
('Europe/Amsterdam', '%y-%m-%d %H:%M', 'de-de', '24-03-13 02:30'),
|
||||||
]
|
]
|
||||||
|
|
||||||
for tz, fmt, expected in tests:
|
for tz, fmt, locale, expected in tests:
|
||||||
result = report_tags.format_datetime(time, tz, fmt)
|
print(tz, fmt, locale, expected)
|
||||||
|
result = report_tags.format_datetime(
|
||||||
|
time, timezone=tz, fmt=fmt, locale=locale
|
||||||
|
)
|
||||||
self.assertEqual(result, expected)
|
self.assertEqual(result, expected)
|
||||||
|
|
||||||
def test_icon(self):
|
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(m, decimal_places=3), '$1,234.560')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
report_tags.render_currency(
|
report_tags.render_currency(
|
||||||
Money(1234, 'USD'), currency='EUR', min_decimal_places=3
|
Money(1234, 'USD'), currency='EUR', decimal_places=3
|
||||||
),
|
),
|
||||||
'$1,234.000',
|
'$1,234.000',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
set_global_setting('PRICING_DECIMAL_PLACES_MIN', 2)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
report_tags.render_currency(
|
report_tags.render_currency(
|
||||||
Money(1234, 'USD'), currency='EUR', max_decimal_places=1
|
Money(1234, 'USD'), currency='EUR', max_decimal_places=1
|
||||||
),
|
),
|
||||||
'$1,234.0',
|
'$1,234.00',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test with non-currency values
|
# Test with non-currency values
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
report_tags.render_currency(1234.45, currency='USD', decimal_places=2),
|
report_tags.render_currency(1234.45, currency='USD', decimal_places=5),
|
||||||
'$1,234.45',
|
'$1,234.45000',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test with an invalid amount
|
# Test with an invalid amount
|
||||||
@@ -544,9 +585,101 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
report_tags.render_currency(m, multiplier='quork')
|
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, 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)
|
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):
|
def test_create_currency(self):
|
||||||
"""Test the create_currency template tag."""
|
"""Test the create_currency template tag."""
|
||||||
m = report_tags.create_currency(1000, 'USD')
|
m = report_tags.create_currency(1000, 'USD')
|
||||||
@@ -618,14 +751,94 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
|||||||
|
|
||||||
def test_format_date(self):
|
def test_format_date(self):
|
||||||
"""Test the format_date template tag."""
|
"""Test the format_date template tag."""
|
||||||
# Test with a valid date
|
dt = timezone.datetime(year=2024, month=3, day=13)
|
||||||
date = 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(date), '2024-03-13')
|
self.assertEqual(report_tags.format_date(dt, locale='en-us'), 'Mar 13, 2024')
|
||||||
self.assertEqual(report_tags.format_date(date, fmt='%d-%m-%y'), '13-03-24')
|
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
|
# Test with an invalid date
|
||||||
self.assertEqual(report_tags.format_date('abc'), 'abc')
|
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):
|
class BarcodeTagTest(TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user