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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user