2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-11-01 05:35:42 +00:00

Report helpers (#10726)

* New report functions:

- create_currency: Create a new Money object
- convert_currency: Convert from one currency to another

* docs

* More checking on report tags

* Better formatting of report errors

* Add unit tests

* Remove error message

* Fix pathing for docs

* Add type hints

* Adjust unit tests
This commit is contained in:
Oliver
2025-10-31 21:23:10 +11:00
committed by GitHub
parent 46ea541bc4
commit 0527d78ae6
4 changed files with 203 additions and 17 deletions

View File

@@ -261,6 +261,27 @@ Total Price: {% render_currency order.total_price currency='NZD' decimal_places=
{% endraw %}
```
### convert_currency
To convert a currency value from one currency to another, use the `convert_currency` helper function:
::: report.templatetags.report.convert_currency
options:
show_docstring_description: false
show_source: False
!!! info "Data Types"
The `money` parameter must be `Money` class instance. If not, an error will be raised.
#### create_currency
Create a `currency` instance using the `create_currency` helper function. This returns a `Money` class instance based on the provided amount and currency type.
::: report.templatetags.report.create_currency
options:
show_docstring_description: false
show_source: False
## Maths Operations
Simple mathematical operators are available, as demonstrated in the example template below. These operators can be used to perform basic arithmetic operations within the report template.

View File

@@ -15,8 +15,12 @@ from django.db.models.query import QuerySet
from django.utils.safestring import SafeString, mark_safe
from django.utils.translation import gettext_lazy as _
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from PIL import Image
import common.currency
import common.icons
import InvenTree.helpers
import InvenTree.helpers_model
@@ -451,7 +455,17 @@ def cast_to_type(value: Any, cast: type) -> Any:
def debug_vars(x: Any, y: Any) -> str:
"""Return a debug string showing the types and values of two variables."""
return f": x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
return f"x='{x}' ({type(x).__name__}), y='{y}' ({type(y).__name__})"
def check_nulls(func: str, *arg):
"""Check if any of the provided arguments is null.
Raises:
ValueError: If any argument is None
"""
if any(a is None for a in arg):
raise ValidationError(f'{func}: {_("Null value provided to function")}')
@register.simple_tag()
@@ -466,11 +480,13 @@ def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be added together
"""
check_nulls('add', x, y)
try:
result = make_decimal(x) + make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot add values of incompatible types') + debug_vars(x, y)
f'add: {_("Cannot add values of incompatible types")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -487,11 +503,13 @@ def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be subtracted
"""
check_nulls('subtract', x, y)
try:
result = make_decimal(x) - make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot subtract values of incompatible types') + debug_vars(x, y)
f'subtract: {_("Cannot subtract values of incompatible types")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -509,11 +527,13 @@ def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be multiplied together
"""
check_nulls('multiply', x, y)
try:
result = make_decimal(x) * make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot multiply values of incompatible types') + debug_vars(x, y)
f'multiply: {_("Cannot multiply values of incompatible types")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -531,14 +551,18 @@ def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be divided
"""
check_nulls('divide', x, y)
try:
result = make_decimal(x) / make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot divide values of incompatible types') + debug_vars(x, y)
f'divide: {_("Cannot divide values of incompatible types")}: {debug_vars(x, y)}'
)
except ZeroDivisionError:
raise ValidationError(_('Cannot divide by zero') + debug_vars(x, y))
raise ValidationError(
f'divide: {_("Cannot divide by zero")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -555,16 +579,17 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises:
ValidationError: If the values cannot be used in a modulo operation
"""
check_nulls('modulo', x, y)
try:
result = make_decimal(x) % make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot perform modulo operation with values of incompatible types')
+ debug_vars(x, y)
f'modulo: {_("Cannot perform modulo operation with values of incompatible types")} {debug_vars(x, y)}'
)
except ZeroDivisionError:
raise ValidationError(
_('Cannot perform modulo operation with divisor of zero') + debug_vars(x, y)
f'modulo: {_("Cannot perform modulo operation with divisor of zero")}: {debug_vars(x, y)}'
)
return cast_to_type(result, cast)
@@ -576,6 +601,70 @@ def render_currency(money, **kwargs):
return InvenTree.helpers_model.render_currency(money, **kwargs)
@register.simple_tag
def create_currency(
amount: Union[str, int, float, Decimal], currency: Optional[str] = None, **kwargs
):
"""Create a Money object, with the provided amount and currency.
Arguments:
amount: The numeric amount (a numeric type or string)
currency: The currency code (e.g. 'USD', 'EUR', etc.)
Note: If the currency is not provided, the default system currency will be used.
"""
check_nulls('create_currency', amount)
currency = currency or common.currency.currency_code_default()
currency = currency.strip().upper()
if currency not in common.currency.CURRENCIES:
raise ValidationError(
f'create_currency: {_("Invalid currency code")}: {currency}'
)
try:
money = Money(amount, currency)
except InvalidOperation:
raise ValidationError(f'create_currency: {_("Invalid amount")}: {amount}')
return money
@register.simple_tag
def convert_currency(money: Money, currency: Optional[str] = None, **kwargs):
"""Convert a Money object to the specified currency.
Arguments:
money: The Money instance to be converted
currency: The target currency code (e.g. 'USD', 'EUR', etc.)
Note: If the currency is not provided, the default system currency will be used.
"""
check_nulls('convert_currency', money)
if not isinstance(money, Money):
raise TypeError('convert_currency tag requires a Money instance')
currency = currency or common.currency.currency_code_default()
currency = currency.strip().upper()
if currency not in common.currency.CURRENCIES:
raise ValidationError(
f'convert_currency: {_("Invalid currency code")}: {currency}'
)
try:
converted = convert_money(money, currency)
except MissingRate:
# Re-throw error with more context
raise ValidationError(
f'convert_currency: {_("Missing exchange rate")} {money.currency} -> {currency}'
)
return converted
@register.simple_tag
def render_html_text(text: str, **kwargs):
"""Render a text item with some simple html tags.
@@ -622,6 +711,8 @@ def format_number(
leading: Number of leading zeros (default = 0)
separator: Character to use as a thousands separator (default = None)
"""
check_nulls('format_number', number)
try:
number = Decimal(str(number).strip())
except Exception:
@@ -681,6 +772,8 @@ def format_datetime(
timezone: The timezone to use for the date (defaults to the server timezone)
fmt: The format string to use (defaults to ISO formatting)
"""
check_nulls('format_datetime', dt)
dt = InvenTree.helpers.to_local_time(dt, timezone)
if fmt:
@@ -698,6 +791,8 @@ def format_date(dt: date, timezone: Optional[str] = None, fmt: Optional[str] = N
timezone: The timezone to use for the date (defaults to the server timezone)
fmt: The format string to use (defaults to ISO formatting)
"""
check_nulls('format_date', dt)
try:
dt = InvenTree.helpers.to_local_time(dt, timezone).date()
except TypeError:

View File

@@ -270,7 +270,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
"""Simple tests for number formatting tags."""
fn = report_tags.format_number
self.assertEqual(fn(None), 'None')
# Passing None should raise an error
with self.assertRaises(ValidationError):
fn(None)
for i in [1, '1', '1.0000', ' 1 ']:
self.assertEqual(fn(i), '1')
@@ -279,6 +281,8 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.assertEqual(fn(x), '10')
self.assertEqual(fn(1234), '1234')
self.assertEqual(fn(1234.5678, decimal_places=0), '1235')
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')
@@ -449,6 +453,55 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
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)
def test_create_currency(self):
"""Test the create_currency template tag."""
m = report_tags.create_currency(1000, 'USD')
self.assertIsInstance(m, Money)
self.assertEqual(m.amount, Decimal('1000'))
self.assertEqual(str(m.currency), 'USD')
# Test with invalid currency code
with self.assertRaises(ValidationError):
report_tags.create_currency(1000, 'QWERTY')
# Test with invalid amount
with self.assertRaises(ValidationError):
report_tags.create_currency('abc', 'USD')
def test_convert_currency(self):
"""Test the convert_currency template tag."""
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
# Generate some dummy exchange rates
rates = {'AUD': 1.5, 'CAD': 1.7, 'GBP': 0.9, 'USD': 1.0}
# Create a dummy backend
ExchangeBackend.objects.create(name='InvenTreeExchange', base_currency='USD')
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
items = []
for currency, rate in rates.items():
items.append(Rate(currency=currency, value=rate, backend=backend))
Rate.objects.bulk_create(items)
m = report_tags.create_currency(1000, 'GBP')
# Test with valid conversion
converted = report_tags.convert_currency(m, 'CAD')
self.assertIsInstance(converted, Money)
self.assertEqual(str(converted.currency), 'CAD')
# Test with invalid currency code
with self.assertRaises(ValidationError):
report_tags.convert_currency(m, 'QWERTY')
# Test with missing exchange rate
with self.assertRaises(ValidationError):
report_tags.convert_currency(m, 'AFD')
def test_render_html_text(self):
"""Test the render_html_text template tag."""
# Test with a valid text

View File

@@ -2,8 +2,9 @@ import { t } from '@lingui/core/macro';
import {
Alert,
CloseButton,
Code,
Group,
List,
ListItem,
Overlay,
Stack,
Tabs
@@ -91,7 +92,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
const [previewItem, setPreviewItem] = useState<string>('');
const [errorOverlay, setErrorOverlay] = useState(null);
const [renderingErrors, setRenderingErrors] = useState<string[] | null>(null);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
@@ -210,7 +211,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
)
)
.then(() => {
setErrorOverlay(null);
setRenderingErrors(null);
notifications.hide('template-preview');
@@ -222,7 +223,19 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
});
})
.catch((error) => {
setErrorOverlay(error.message);
const msg = error?.message;
if (msg) {
if (Array.isArray(msg)) {
setRenderingErrors(msg);
} else {
setRenderingErrors([msg]);
}
} else {
setRenderingErrors([
t`An unknown error occurred while rendering the preview.`
]);
}
})
.finally(() => {
setIsPreviewLoading(false);
@@ -392,10 +405,10 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
{/* @ts-ignore-next-line */}
<PreviewArea.component ref={previewRef} />
{errorOverlay && (
{renderingErrors && (
<Overlay color='red' center blur={0.2}>
<CloseButton
onClick={() => setErrorOverlay(null)}
onClick={() => setRenderingErrors(null)}
style={{
position: 'absolute',
top: '10px',
@@ -410,7 +423,11 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
title={t`Error rendering template`}
mx='10px'
>
<Code>{errorOverlay}</Code>
<List>
{renderingErrors.map((error, index) => (
<ListItem key={index}>{error}</ListItem>
))}
</List>
</Alert>
</Overlay>
)}