mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-16 09:18:10 +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:
@@ -6,6 +6,7 @@ from typing import Optional, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -184,6 +185,7 @@ 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,
|
||||
@@ -194,6 +196,7 @@ def render_currency(
|
||||
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
|
||||
@@ -202,7 +205,16 @@ def render_currency(
|
||||
return '-'
|
||||
|
||||
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:
|
||||
# Attempt to convert to the provided currency
|
||||
@@ -212,6 +224,14 @@ def render_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)
|
||||
|
||||
|
||||
@@ -70,12 +70,6 @@ def str2bool(x, *args, **kwargs):
|
||||
return InvenTree.helpers.str2bool(x)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def add(x, y, *args, **kwargs):
|
||||
"""Add two numbers together."""
|
||||
return x + y
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def to_list(*args):
|
||||
"""Return the input arguments as list."""
|
||||
|
||||
@@ -39,10 +39,6 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
self.assertEqual(int(inventree_extras.str2bool('none')), 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):
|
||||
"""Test the 'instance name' setting."""
|
||||
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree')
|
||||
|
||||
@@ -546,6 +546,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
msg = _('Template syntax error')
|
||||
output.mark_failure(msg)
|
||||
raise ValidationError(f'{msg}: {e!s}')
|
||||
except ValidationError as e:
|
||||
output.mark_failure(str(e))
|
||||
raise e
|
||||
except Exception as e:
|
||||
msg = _('Error rendering report')
|
||||
output.mark_failure(msg)
|
||||
@@ -582,6 +585,9 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
|
||||
msg = _('Template syntax error')
|
||||
output.mark_failure(error=_('Template syntax error'))
|
||||
raise ValidationError(f'{msg}: {e!s}')
|
||||
except ValidationError as e:
|
||||
output.mark_failure(str(e))
|
||||
raise e
|
||||
except Exception as e:
|
||||
msg = _('Error rendering report')
|
||||
output.mark_failure(error=msg)
|
||||
|
||||
@@ -4,7 +4,7 @@ import base64
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from django import template
|
||||
@@ -413,54 +413,161 @@ def internal_link(link, text) -> str:
|
||||
return mark_safe(f'<a href="{url}">{text}</a>')
|
||||
|
||||
|
||||
def destringify(value: Any) -> Any:
|
||||
"""Convert a string value into a float.
|
||||
def make_decimal(value: Any) -> Any:
|
||||
"""Convert an input value into a Decimal.
|
||||
|
||||
- If the value is a string, attempt to convert it to a float.
|
||||
- If conversion fails, return the original string.
|
||||
- If the value is not a string, return it unchanged.
|
||||
- Converts [string, int, float] types into Decimal
|
||||
- If conversion fails, returns the original value
|
||||
|
||||
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.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if any(isinstance(value, t) for t in [int, float, str]):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
value = Decimal(str(value).strip())
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
logger.warning(
|
||||
'make_decimal: Failed to convert value to Decimal: %s (%s)',
|
||||
value,
|
||||
type(value),
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def add(x: Any, y: Any) -> Any:
|
||||
"""Add two numbers (or number like values) together."""
|
||||
return destringify(x) + destringify(y)
|
||||
def cast_to_type(value: Any, cast: type) -> Any:
|
||||
"""Attempt to cast a value to the provided type.
|
||||
|
||||
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()
|
||||
def subtract(x: Any, y: Any) -> Any:
|
||||
"""Subtract one number (or number-like value) from another."""
|
||||
return destringify(x) - destringify(y)
|
||||
def add(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
||||
"""Add two numbers (or number like values) together.
|
||||
|
||||
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()
|
||||
def multiply(x: Any, y: Any) -> Any:
|
||||
"""Multiply two numbers (or number-like values) together."""
|
||||
return destringify(x) * destringify(y)
|
||||
def subtract(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
||||
"""Subtract one number (or number-like value) from another.
|
||||
|
||||
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()
|
||||
def divide(x: Any, y: Any) -> Any:
|
||||
"""Divide one number (or number-like value) by another."""
|
||||
return destringify(x) / destringify(y)
|
||||
def multiply(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
||||
"""Multiply two numbers (or number-like values) together.
|
||||
|
||||
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()
|
||||
def modulo(x: Any, y: Any) -> Any:
|
||||
"""Calculate the modulo of one number (or number-like value) by another."""
|
||||
return destringify(x) % destringify(y)
|
||||
def divide(x: Any, y: Any, cast: Optional[type] = None) -> Any:
|
||||
"""Divide one number (or number-like value) by another.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test for custom report tags."""
|
||||
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.conf import settings
|
||||
@@ -198,23 +199,27 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
||||
"""Simple tests for mathematical operator tags."""
|
||||
self.assertEqual(report_tags.add(1, 2), 3)
|
||||
self.assertEqual(report_tags.add('-33', '33'), 0)
|
||||
self.assertEqual(report_tags.subtract(10, 4.2), 5.8)
|
||||
self.assertEqual(report_tags.multiply(2.3, 4), 9.2)
|
||||
self.assertEqual(report_tags.multiply('-2', 4), -8.0)
|
||||
self.assertEqual(report_tags.add(4.5, Decimal(4.5), cast=float), 9.0)
|
||||
self.assertEqual(report_tags.subtract(10, 4.2, cast=float), 5.8)
|
||||
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.modulo(10, 3), 1)
|
||||
self.assertEqual(report_tags.modulo('10', '4'), 2)
|
||||
|
||||
with self.assertRaises(ZeroDivisionError):
|
||||
with self.assertRaises(ValidationError):
|
||||
report_tags.divide(100, 0)
|
||||
|
||||
def test_maths_tags_with_strings(self):
|
||||
"""Tests for mathematical operator tags with string inputs."""
|
||||
self.assertEqual(report_tags.add('10', '20'), 30)
|
||||
self.assertEqual(report_tags.subtract('50.5', '20.2'), 30.3)
|
||||
self.assertEqual(report_tags.add(Decimal('10'), '20'), 30)
|
||||
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.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):
|
||||
"""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')
|
||||
)
|
||||
|
||||
self.assertEqual(report_tags.multiply(3.3, Decimal('2.0')), Decimal('6.6'))
|
||||
|
||||
def test_maths_tags_with_money(self):
|
||||
"""Tests for mathematical operator tags with Money inputs."""
|
||||
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.subtract(m1, m2), Money(50, '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'))
|
||||
|
||||
result = report_tags.divide(Money(1000, 'GBP'), 4)
|
||||
self.assertIsInstance(result, Money)
|
||||
|
||||
def test_maths_tags_invalid(self):
|
||||
"""Tests for mathematical operator tags with invalid inputs."""
|
||||
with self.assertRaises(TypeError):
|
||||
with self.assertRaises(ValidationError):
|
||||
report_tags.add('abc', 10)
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
with self.assertRaises(ValidationError):
|
||||
report_tags.subtract(50, 'xyz')
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
with self.assertRaises(ValidationError):
|
||||
report_tags.multiply('foo', 'bar')
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
with self.assertRaises(ValidationError):
|
||||
report_tags.divide('100', 'baz')
|
||||
|
||||
def test_number_tags(self):
|
||||
@@ -410,8 +421,19 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
|
||||
'$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
|
||||
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, min_decimal_places='a'), exp_m)
|
||||
self.assertEqual(report_tags.render_currency(m, max_decimal_places='a'), exp_m)
|
||||
|
||||
@@ -587,9 +587,11 @@ export function ApiForm({
|
||||
<Alert radius='sm' color='red' title={t`Form Error`}>
|
||||
{nonFieldErrors.length > 0 ? (
|
||||
<Stack gap='xs'>
|
||||
{nonFieldErrors.map((message) => (
|
||||
<Text key={message}>{message}</Text>
|
||||
))}
|
||||
{nonFieldErrors
|
||||
.filter((message) => !!message && message !== 'None')
|
||||
.map((message) => (
|
||||
<Text key={message}>{message}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>{t`Errors exist for one or more form fields`}</Text>
|
||||
|
||||
Reference in New Issue
Block a user