2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-29 20:30:39 +00:00

Report tag fixes (#10668)

* remove duplicate template tag

* Add "multiplier" argument to render_currency

* Improve render_currency

- Enable conversion of non-money values to a Money instance

* Improve maths tags

- Convert values to Decimal
- Ability to cast result to different type

* Updated docs

* Improved feedback from maths tags

* Updated unit testing

* Improved rendering of printing errors

* Add extra test for render_currency tag

* Enfoce multiplier type

* Fix docstrings

* Improved error handling

* Remove defunct unit test

* Fix unit tests
This commit is contained in:
Oliver
2025-10-25 13:17:10 +11:00
committed by GitHub
parent a2682a75e9
commit 8e1d621db9
8 changed files with 201 additions and 54 deletions

View File

@@ -267,7 +267,7 @@ Simple mathematical operators are available, as demonstrated in the example temp
### Input Types ### Input Types
These mathematical functions accept inputs of various input types, and attempt to perform the operation accordingly. Note that any inputs which are provided as strings will be converted to floating point numbers before the operation is performed. These mathematical functions accept inputs of various input types, and attempt to perform the operation accordingly. Note that any inputs which are provided as strings or numbers will be converted to `Decimal` class types before the operation is performed.
### add ### add

View File

@@ -6,6 +6,7 @@ from typing import Optional, cast
from urllib.parse import urljoin from urllib.parse import urljoin
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 _
@@ -184,6 +185,7 @@ def render_currency(
money: Money, money: Money,
decimal_places: Optional[int] = None, decimal_places: Optional[int] = None,
currency: Optional[str] = None, currency: Optional[str] = None,
multiplier: Optional[Decimal] = None,
min_decimal_places: Optional[int] = None, min_decimal_places: Optional[int] = None,
max_decimal_places: Optional[int] = None, max_decimal_places: Optional[int] = None,
include_symbol: bool = True, include_symbol: bool = True,
@@ -194,6 +196,7 @@ def render_currency(
money: The Money instance to be rendered 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. 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 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. 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. 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 include_symbol: If True, include the currency symbol in the output
@@ -202,7 +205,16 @@ def render_currency(
return '-' return '-'
if type(money) is not Money: if type(money) is not Money:
return '-' # 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: if currency is not None:
# Attempt to convert to the provided currency # Attempt to convert to the provided currency
@@ -212,6 +224,14 @@ def render_currency(
except Exception: except Exception:
pass 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)): 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) min_decimal_places = get_global_setting('PRICING_DECIMAL_PLACES_MIN', 0)

View File

@@ -70,12 +70,6 @@ def str2bool(x, *args, **kwargs):
return InvenTree.helpers.str2bool(x) return InvenTree.helpers.str2bool(x)
@register.simple_tag()
def add(x, y, *args, **kwargs):
"""Add two numbers together."""
return x + y
@register.simple_tag() @register.simple_tag()
def to_list(*args): def to_list(*args):
"""Return the input arguments as list.""" """Return the input arguments as list."""

View File

@@ -39,10 +39,6 @@ class TemplateTagTest(InvenTreeTestCase):
self.assertEqual(int(inventree_extras.str2bool('none')), False) self.assertEqual(int(inventree_extras.str2bool('none')), False)
self.assertEqual(int(inventree_extras.str2bool('off')), False) self.assertEqual(int(inventree_extras.str2bool('off')), False)
def test_add(self):
"""Test that the 'add."""
self.assertEqual(int(inventree_extras.add(3, 5)), 8)
def test_inventree_instance_name(self): def test_inventree_instance_name(self):
"""Test the 'instance name' setting.""" """Test the 'instance name' setting."""
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree') self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree')

View File

@@ -546,6 +546,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
msg = _('Template syntax error') msg = _('Template syntax error')
output.mark_failure(msg) output.mark_failure(msg)
raise ValidationError(f'{msg}: {e!s}') raise ValidationError(f'{msg}: {e!s}')
except ValidationError as e:
output.mark_failure(str(e))
raise e
except Exception as e: except Exception as e:
msg = _('Error rendering report') msg = _('Error rendering report')
output.mark_failure(msg) output.mark_failure(msg)
@@ -582,6 +585,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
msg = _('Template syntax error') msg = _('Template syntax error')
output.mark_failure(error=_('Template syntax error')) output.mark_failure(error=_('Template syntax error'))
raise ValidationError(f'{msg}: {e!s}') raise ValidationError(f'{msg}: {e!s}')
except ValidationError as e:
output.mark_failure(str(e))
raise e
except Exception as e: except Exception as e:
msg = _('Error rendering report') msg = _('Error rendering report')
output.mark_failure(error=msg) output.mark_failure(error=msg)

View File

@@ -4,7 +4,7 @@ import base64
import logging import logging
import os import os
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal, InvalidOperation
from typing import Any, Optional, Union from typing import Any, Optional, Union
from django import template from django import template
@@ -413,54 +413,161 @@ def internal_link(link, text) -> str:
return mark_safe(f'<a href="{url}">{text}</a>') return mark_safe(f'<a href="{url}">{text}</a>')
def destringify(value: Any) -> Any: def make_decimal(value: Any) -> Any:
"""Convert a string value into a float. """Convert an input value into a Decimal.
- If the value is a string, attempt to convert it to a float. - Converts [string, int, float] types into Decimal
- If conversion fails, return the original string. - If conversion fails, returns the original value
- If the value is not a string, return it unchanged.
The purpose of this function is to provide "seamless" math operations in templates, The purpose of this function is to provide "seamless" math operations in templates,
where numeric values may be provided as strings, or converted to strings during template rendering. where numeric values may be provided as strings, or converted to strings during template rendering.
""" """
if isinstance(value, str): if any(isinstance(value, t) for t in [int, float, str]):
value = value.strip()
try: try:
return float(value) value = Decimal(str(value).strip())
except ValueError: except (InvalidOperation, TypeError, ValueError):
return value logger.warning(
'make_decimal: Failed to convert value to Decimal: %s (%s)',
value,
type(value),
)
return value return value
@register.simple_tag() def cast_to_type(value: Any, cast: type) -> Any:
def add(x: Any, y: Any) -> Any: """Attempt to cast a value to the provided type.
"""Add two numbers (or number like values) together."""
return destringify(x) + destringify(y) If casting fails, the original value is returned.
"""
if cast is not None:
try:
value = cast(value)
except (ValueError, TypeError):
pass
return value
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__})"
@register.simple_tag() @register.simple_tag()
def subtract(x: Any, y: Any) -> Any: def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
"""Subtract one number (or number-like value) from another.""" """Add two numbers (or number like values) together.
return destringify(x) - destringify(y)
Arguments:
x: The first value to add
y: The second value to add
cast: Optional type to cast the result to (e.g. int, float, str)
Raises:
ValidationError: If the values cannot be added together
"""
try:
result = make_decimal(x) + make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot add values of incompatible types') + debug_vars(x, y)
)
return cast_to_type(result, cast)
@register.simple_tag() @register.simple_tag()
def multiply(x: Any, y: Any) -> Any: def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
"""Multiply two numbers (or number-like values) together.""" """Subtract one number (or number-like value) from another.
return destringify(x) * destringify(y)
Arguments:
x: The value to be subtracted from
y: The value to be subtracted
cast: Optional type to cast the result to (e.g. int, float, str)
Raises:
ValidationError: If the values cannot be subtracted
"""
try:
result = make_decimal(x) - make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot subtract values of incompatible types') + debug_vars(x, y)
)
return cast_to_type(result, cast)
@register.simple_tag() @register.simple_tag()
def divide(x: Any, y: Any) -> Any: def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
"""Divide one number (or number-like value) by another.""" """Multiply two numbers (or number-like values) together.
return destringify(x) / destringify(y)
Arguments:
x: The first value to multiply
y: The second value to multiply
cast: Optional type to cast the result to (e.g. int, float, str)
Raises:
ValidationError: If the values cannot be multiplied together
"""
try:
result = make_decimal(x) * make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot multiply values of incompatible types') + debug_vars(x, y)
)
return cast_to_type(result, cast)
@register.simple_tag() @register.simple_tag()
def modulo(x: Any, y: Any) -> Any: def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
"""Calculate the modulo of one number (or number-like value) by another.""" """Divide one number (or number-like value) by another.
return destringify(x) % destringify(y)
Arguments:
x: The value to be divided
y: The value to divide by
cast: Optional type to cast the result to (e.g. int, float, str)
Raises:
ValidationError: If the values cannot be divided
"""
try:
result = make_decimal(x) / make_decimal(y)
except (InvalidOperation, TypeError, ValueError):
raise ValidationError(
_('Cannot divide values of incompatible types') + debug_vars(x, y)
)
except ZeroDivisionError:
raise ValidationError(_('Cannot divide by zero') + debug_vars(x, y))
return cast_to_type(result, cast)
@register.simple_tag()
def modulo(x: Any, y: Any, cast: Optional[type] = None) -> Any:
"""Calculate the modulo of one number (or number-like value) by another.
Arguments:
x: The first value to be used in the modulo operation
y: The second value to be used in the modulo operation
cast: Optional type to cast the result to (e.g. int, float, str)
Raises:
ValidationError: If the values cannot be used in a modulo operation
"""
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)
)
except ZeroDivisionError:
raise ValidationError(
_('Cannot perform modulo operation with divisor of zero') + debug_vars(x, y)
)
return cast_to_type(result, cast)
@register.simple_tag @register.simple_tag

View File

@@ -1,5 +1,6 @@
"""Test for custom report tags.""" """Test for custom report tags."""
from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from django.conf import settings from django.conf import settings
@@ -198,23 +199,27 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
"""Simple tests for mathematical operator tags.""" """Simple tests for mathematical operator tags."""
self.assertEqual(report_tags.add(1, 2), 3) self.assertEqual(report_tags.add(1, 2), 3)
self.assertEqual(report_tags.add('-33', '33'), 0) self.assertEqual(report_tags.add('-33', '33'), 0)
self.assertEqual(report_tags.subtract(10, 4.2), 5.8) self.assertEqual(report_tags.add(4.5, Decimal(4.5), cast=float), 9.0)
self.assertEqual(report_tags.multiply(2.3, 4), 9.2) self.assertEqual(report_tags.subtract(10, 4.2, cast=float), 5.8)
self.assertEqual(report_tags.multiply('-2', 4), -8.0) self.assertEqual(report_tags.multiply(2.3, 4, cast=str), '9.2')
self.assertEqual(report_tags.multiply('-2', 4), -8)
self.assertEqual(report_tags.divide(100, 5), 20) self.assertEqual(report_tags.divide(100, 5), 20)
self.assertEqual(report_tags.modulo(10, 3), 1) self.assertEqual(report_tags.modulo(10, 3), 1)
self.assertEqual(report_tags.modulo('10', '4'), 2) self.assertEqual(report_tags.modulo('10', '4'), 2)
with self.assertRaises(ZeroDivisionError): with self.assertRaises(ValidationError):
report_tags.divide(100, 0) report_tags.divide(100, 0)
def test_maths_tags_with_strings(self): def test_maths_tags_with_strings(self):
"""Tests for mathematical operator tags with string inputs.""" """Tests for mathematical operator tags with string inputs."""
self.assertEqual(report_tags.add('10', '20'), 30) self.assertEqual(report_tags.add(Decimal('10'), '20'), 30)
self.assertEqual(report_tags.subtract('50.5', '20.2'), 30.3) self.assertEqual(report_tags.subtract('50.5', '20.2'), Decimal('30.3'))
self.assertEqual(report_tags.multiply(3.0000000000000, '7'), 21) self.assertEqual(report_tags.multiply(3.0000000000000, '7'), 21)
self.assertEqual(report_tags.divide('100.0', '4'), 25.0)
result = report_tags.divide(100, Decimal('4'), cast=int)
self.assertEqual(result, 25)
self.assertIsInstance(result, int)
def test_maths_tags_with_decimal(self): def test_maths_tags_with_decimal(self):
"""Tests for mathematical operator tags with Decimal inputs.""" """Tests for mathematical operator tags with Decimal inputs."""
@@ -231,6 +236,8 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
report_tags.divide(Decimal('10.0'), Decimal('2.000')), Decimal('5.0') report_tags.divide(Decimal('10.0'), Decimal('2.000')), Decimal('5.0')
) )
self.assertEqual(report_tags.multiply(3.3, Decimal('2.0')), Decimal('6.6'))
def test_maths_tags_with_money(self): def test_maths_tags_with_money(self):
"""Tests for mathematical operator tags with Money inputs.""" """Tests for mathematical operator tags with Money inputs."""
m1 = Money(100, 'USD') m1 = Money(100, 'USD')
@@ -239,20 +246,24 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.assertEqual(report_tags.add(m1, m2), Money(150, 'USD')) self.assertEqual(report_tags.add(m1, m2), Money(150, 'USD'))
self.assertEqual(report_tags.subtract(m1, m2), Money(50, 'USD')) self.assertEqual(report_tags.subtract(m1, m2), Money(50, 'USD'))
self.assertEqual(report_tags.multiply(m2, 3), Money(150, 'USD')) self.assertEqual(report_tags.multiply(m2, 3), Money(150, 'USD'))
self.assertEqual(report_tags.multiply(-3, m2), Money(-150, 'USD'))
self.assertEqual(report_tags.divide(m1, '4'), Money(25, 'USD')) self.assertEqual(report_tags.divide(m1, '4'), Money(25, 'USD'))
result = report_tags.divide(Money(1000, 'GBP'), 4)
self.assertIsInstance(result, Money)
def test_maths_tags_invalid(self): def test_maths_tags_invalid(self):
"""Tests for mathematical operator tags with invalid inputs.""" """Tests for mathematical operator tags with invalid inputs."""
with self.assertRaises(TypeError): with self.assertRaises(ValidationError):
report_tags.add('abc', 10) report_tags.add('abc', 10)
with self.assertRaises(TypeError): with self.assertRaises(ValidationError):
report_tags.subtract(50, 'xyz') report_tags.subtract(50, 'xyz')
with self.assertRaises(TypeError): with self.assertRaises(ValidationError):
report_tags.multiply('foo', 'bar') report_tags.multiply('foo', 'bar')
with self.assertRaises(TypeError): with self.assertRaises(ValidationError):
report_tags.divide('100', 'baz') report_tags.divide('100', 'baz')
def test_number_tags(self): def test_number_tags(self):
@@ -410,8 +421,19 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
'$1,234.0', '$1,234.0',
) )
# Test with non-currency values
self.assertEqual(
report_tags.render_currency(1234.45, currency='USD', decimal_places=2),
'$1,234.45',
)
# Test with an invalid amount # Test with an invalid amount
self.assertEqual(report_tags.render_currency('abc'), '-') with self.assertRaises(ValidationError):
report_tags.render_currency('abc', currency='-')
with self.assertRaises(ValidationError):
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, 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)

View File

@@ -587,9 +587,11 @@ export function ApiForm({
<Alert radius='sm' color='red' title={t`Form Error`}> <Alert radius='sm' color='red' title={t`Form Error`}>
{nonFieldErrors.length > 0 ? ( {nonFieldErrors.length > 0 ? (
<Stack gap='xs'> <Stack gap='xs'>
{nonFieldErrors.map((message) => ( {nonFieldErrors
<Text key={message}>{message}</Text> .filter((message) => !!message && message !== 'None')
))} .map((message) => (
<Text key={message}>{message}</Text>
))}
</Stack> </Stack>
) : ( ) : (
<Text>{t`Errors exist for one or more form fields`}</Text> <Text>{t`Errors exist for one or more form fields`}</Text>