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