2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-11-01 21:55:43 +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 %} {% 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 ## 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. 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.safestring import SafeString, mark_safe
from django.utils.translation import gettext_lazy as _ 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 from PIL import Image
import common.currency
import common.icons import common.icons
import InvenTree.helpers import InvenTree.helpers
import InvenTree.helpers_model 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: def debug_vars(x: Any, y: Any) -> str:
"""Return a debug string showing the types and values of two variables.""" """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() @register.simple_tag()
@@ -466,11 +480,13 @@ def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises: Raises:
ValidationError: If the values cannot be added together ValidationError: If the values cannot be added together
""" """
check_nulls('add', x, y)
try: try:
result = make_decimal(x) + make_decimal(y) result = make_decimal(x) + make_decimal(y)
except (InvalidOperation, TypeError, ValueError): except (InvalidOperation, TypeError, ValueError):
raise ValidationError( 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) return cast_to_type(result, cast)
@@ -487,11 +503,13 @@ def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises: Raises:
ValidationError: If the values cannot be subtracted ValidationError: If the values cannot be subtracted
""" """
check_nulls('subtract', x, y)
try: try:
result = make_decimal(x) - make_decimal(y) result = make_decimal(x) - make_decimal(y)
except (InvalidOperation, TypeError, ValueError): except (InvalidOperation, TypeError, ValueError):
raise ValidationError( 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) return cast_to_type(result, cast)
@@ -509,11 +527,13 @@ def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises: Raises:
ValidationError: If the values cannot be multiplied together ValidationError: If the values cannot be multiplied together
""" """
check_nulls('multiply', x, y)
try: try:
result = make_decimal(x) * make_decimal(y) result = make_decimal(x) * make_decimal(y)
except (InvalidOperation, TypeError, ValueError): except (InvalidOperation, TypeError, ValueError):
raise ValidationError( 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) return cast_to_type(result, cast)
@@ -531,14 +551,18 @@ def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises: Raises:
ValidationError: If the values cannot be divided ValidationError: If the values cannot be divided
""" """
check_nulls('divide', x, y)
try: try:
result = make_decimal(x) / make_decimal(y) result = make_decimal(x) / make_decimal(y)
except (InvalidOperation, TypeError, ValueError): except (InvalidOperation, TypeError, ValueError):
raise ValidationError( 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: 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) return cast_to_type(result, cast)
@@ -555,16 +579,17 @@ def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any:
Raises: Raises:
ValidationError: If the values cannot be used in a modulo operation ValidationError: If the values cannot be used in a modulo operation
""" """
check_nulls('modulo', x, y)
try: try:
result = make_decimal(x) % make_decimal(y) result = make_decimal(x) % make_decimal(y)
except (InvalidOperation, TypeError, ValueError): except (InvalidOperation, TypeError, ValueError):
raise ValidationError( raise ValidationError(
_('Cannot perform modulo operation with values of incompatible types') f'modulo: {_("Cannot perform modulo operation with values of incompatible types")} {debug_vars(x, y)}'
+ debug_vars(x, y)
) )
except ZeroDivisionError: except ZeroDivisionError:
raise ValidationError( 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) return cast_to_type(result, cast)
@@ -576,6 +601,70 @@ def render_currency(money, **kwargs):
return InvenTree.helpers_model.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 @register.simple_tag
def render_html_text(text: str, **kwargs): def render_html_text(text: str, **kwargs):
"""Render a text item with some simple html tags. """Render a text item with some simple html tags.
@@ -622,6 +711,8 @@ def format_number(
leading: Number of leading zeros (default = 0) leading: Number of leading zeros (default = 0)
separator: Character to use as a thousands separator (default = None) separator: Character to use as a thousands separator (default = None)
""" """
check_nulls('format_number', number)
try: try:
number = Decimal(str(number).strip()) number = Decimal(str(number).strip())
except Exception: except Exception:
@@ -681,6 +772,8 @@ def format_datetime(
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 format string to use (defaults to ISO formatting)
""" """
check_nulls('format_datetime', dt)
dt = InvenTree.helpers.to_local_time(dt, timezone) dt = InvenTree.helpers.to_local_time(dt, timezone)
if fmt: 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) 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 format string to use (defaults to ISO formatting)
""" """
check_nulls('format_date', dt)
try: try:
dt = InvenTree.helpers.to_local_time(dt, timezone).date() dt = InvenTree.helpers.to_local_time(dt, timezone).date()
except TypeError: except TypeError:

View File

@@ -270,7 +270,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
"""Simple tests for number formatting tags.""" """Simple tests for number formatting tags."""
fn = report_tags.format_number 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 ']: for i in [1, '1', '1.0000', ' 1 ']:
self.assertEqual(fn(i), '1') self.assertEqual(fn(i), '1')
@@ -279,6 +281,8 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.assertEqual(fn(x), '10') self.assertEqual(fn(x), '10')
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=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(1234.5678, decimal_places=3), '1234.568')
self.assertEqual(fn(-9999.5678, decimal_places=2, separator=','), '-9,999.57') 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, 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)
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): def test_render_html_text(self):
"""Test the render_html_text template tag.""" """Test the render_html_text template tag."""
# Test with a valid text # Test with a valid text

View File

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