diff --git a/InvenTree/InvenTree/format.py b/InvenTree/InvenTree/format.py index afb16bc146..ab4267959f 100644 --- a/InvenTree/InvenTree/format.py +++ b/InvenTree/InvenTree/format.py @@ -3,8 +3,14 @@ import re import string +from django.conf import settings +from django.utils import translation from django.utils.translation import gettext_lazy as _ +from babel import Locale +from babel.numbers import parse_pattern +from djmoney.money import Money + def parse_format_string(fmt_string: str) -> dict: """Extract formatting information from the provided format string. @@ -160,3 +166,34 @@ def extract_named_group(name: str, value: str, fmt_string: str) -> str: # And return the value we are interested in # Note: This will raise an IndexError if the named group was not matched return result.group(name) + + +def format_money(money: Money, decimal_places: int = None, format: str = None) -> str: + """Format money object according to the currently set local + + Args: + decimal_places: Number of decimal places to use + format: Format pattern according LDML / the babel format pattern syntax (https://babel.pocoo.org/en/latest/numbers.html) + + Returns: + str: The formatted string + + Raises: + ValueError: format string is incorrectly specified + """ + language = None and translation.get_language() or settings.LANGUAGE_CODE + locale = Locale.parse(translation.to_locale(language)) + if format: + pattern = parse_pattern(format) + else: + pattern = locale.currency_formats["standard"] + if decimal_places is not None: + pattern.frac_prec = (decimal_places, decimal_places) + + return pattern.apply( + money.amount, + locale, + currency=money.currency.code, + currency_digits=decimal_places is None, + decimal_quantization=decimal_places is not None, + ) diff --git a/InvenTree/InvenTree/helpers_model.py b/InvenTree/InvenTree/helpers_model.py index 2f9272655e..e63419a16f 100644 --- a/InvenTree/InvenTree/helpers_model.py +++ b/InvenTree/InvenTree/helpers_model.py @@ -10,7 +10,6 @@ from django.core.validators import URLValidator from django.db.utils import OperationalError, ProgrammingError from django.utils.translation import gettext_lazy as _ -import moneyed.localization import requests from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money @@ -22,6 +21,7 @@ import InvenTree.helpers_model import InvenTree.version from common.notifications import (InvenTreeNotificationBodies, NotificationBody, trigger_notification) +from InvenTree.format import format_money logger = logging.getLogger('inventree') @@ -167,14 +167,13 @@ def download_image_from_url(remote_url, timeout=2.5): return img -def render_currency(money, decimal_places=None, currency=None, include_symbol=True, min_decimal_places=None, max_decimal_places=None): +def render_currency(money, decimal_places=None, currency=None, min_decimal_places=None, max_decimal_places=None): """Render a currency / Money object to a formatted string (e.g. for reports) Arguments: 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 - include_symbol: Render with the appropriate currency symbol 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. """ @@ -216,11 +215,7 @@ def render_currency(money, decimal_places=None, currency=None, include_symbol=Tr decimal_places = max(decimal_places, max_decimal_places) - return moneyed.localization.format_money( - money, - decimal_places=decimal_places, - include_symbol=include_symbol, - ) + return format_money(money, decimal_places=decimal_places) def getModelsWithMixin(mixin_class) -> list: diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 584f69a246..b0ecab4116 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -330,6 +330,33 @@ class FormatTest(TestCase): "PO-###-{test}", ) + def test_currency_formatting(self): + """Test that currency formatting works correctly for multiple currencies""" + + test_data = ( + (Money( 3651.285718, "USD"), 4, "$3,651.2857" ), # noqa: E201,E202 + (Money(487587.849178, "CAD"), 5, "CA$487,587.84918"), # noqa: E201,E202 + (Money( 0.348102, "EUR"), 1, "€0.3" ), # noqa: E201,E202 + (Money( 0.916530, "GBP"), 1, "£0.9" ), # noqa: E201,E202 + (Money( 61.031024, "JPY"), 3, "¥61.031" ), # noqa: E201,E202 + (Money( 49609.694602, "JPY"), 1, "¥49,609.7" ), # noqa: E201,E202 + (Money(155565.264777, "AUD"), 2, "A$155,565.26" ), # noqa: E201,E202 + (Money( 0.820437, "CNY"), 4, "CN¥0.8204" ), # noqa: E201,E202 + (Money( 7587.849178, "EUR"), 0, "€7,588" ), # noqa: E201,E202 + (Money( 0.348102, "GBP"), 3, "£0.348" ), # noqa: E201,E202 + (Money( 0.652923, "CHF"), 0, "CHF1" ), # noqa: E201,E202 + (Money( 0.820437, "CNY"), 1, "CN¥0.8" ), # noqa: E201,E202 + (Money(98789.5295680, "CHF"), 0, "CHF98,790" ), # noqa: E201,E202 + (Money( 0.585787, "USD"), 1, "$0.6" ), # noqa: E201,E202 + (Money( 0.690541, "CAD"), 3, "CA$0.691" ), # noqa: E201,E202 + (Money( 427.814104, "AUD"), 5, "A$427.81410" ), # noqa: E201,E202 + ) + + with self.settings(LANGUAGE_CODE="en-us"): + for value, decimal_places, expected_result in test_data: + result = InvenTree.format.format_money(value, decimal_places=decimal_places) + assert result == expected_result + class TestHelpers(TestCase): """Tests for InvenTree helper functions.""" diff --git a/InvenTree/company/migrations/0067_alter_supplierpricebreak_price_currency.py b/InvenTree/company/migrations/0067_alter_supplierpricebreak_price_currency.py new file mode 100644 index 0000000000..4c18def143 --- /dev/null +++ b/InvenTree/company/migrations/0067_alter_supplierpricebreak_price_currency.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.22 on 2023-10-24 16:44 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0066_auto_20230616_2059'), + ] + + operations = [ + migrations.AlterField( + model_name='supplierpricebreak', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + ] diff --git a/InvenTree/order/migrations/0098_auto_20231024_1844.py b/InvenTree/order/migrations/0098_auto_20231024_1844.py new file mode 100644 index 0000000000..99f5cbe7dc --- /dev/null +++ b/InvenTree/order/migrations/0098_auto_20231024_1844.py @@ -0,0 +1,59 @@ +# Generated by Django 3.2.22 on 2023-10-24 16:44 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0097_auto_20230529_0107'), + ] + + operations = [ + migrations.AlterField( + model_name='purchaseorder', + name='total_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='purchaseorderextraline', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='purchaseorderlineitem', + name='purchase_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='returnorder', + name='total_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='returnorderextraline', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='returnorderlineitem', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='salesorder', + name='total_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='salesorderextraline', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='salesorderlineitem', + name='sale_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + ] diff --git a/InvenTree/part/migrations/0118_auto_20231024_1844.py b/InvenTree/part/migrations/0118_auto_20231024_1844.py new file mode 100644 index 0000000000..6a29bf89d6 --- /dev/null +++ b/InvenTree/part/migrations/0118_auto_20231024_1844.py @@ -0,0 +1,114 @@ +# Generated by Django 3.2.22 on 2023-10-24 16:44 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0117_remove_part_responsible'), + ] + + operations = [ + migrations.AlterField( + model_name='partinternalpricebreak', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='bom_cost_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='bom_cost_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='internal_cost_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='internal_cost_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='overall_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='overall_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='purchase_cost_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='purchase_cost_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='sale_history_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='sale_history_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='sale_price_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='sale_price_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='supplier_price_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='supplier_price_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='variant_cost_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partpricing', + name='variant_cost_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partsellpricebreak', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partstocktake', + name='cost_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AlterField( + model_name='partstocktake', + name='cost_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + ] diff --git a/InvenTree/stock/migrations/0104_alter_stockitem_purchase_price_currency.py b/InvenTree/stock/migrations/0104_alter_stockitem_purchase_price_currency.py new file mode 100644 index 0000000000..6c25d34404 --- /dev/null +++ b/InvenTree/stock/migrations/0104_alter_stockitem_purchase_price_currency.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.22 on 2023-10-24 16:44 + +from django.db import migrations +import djmoney.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0103_stock_location_types'), + ] + + operations = [ + migrations.AlterField( + model_name='stockitem', + name='purchase_price_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + ] diff --git a/requirements.in b/requirements.in index 8ce9a310b7..4ac187be8e 100644 --- a/requirements.in +++ b/requirements.in @@ -16,7 +16,7 @@ django-ical # iCal export for calendar views django-import-export>=3.3.1 # Data import / export for admin interface django-maintenance-mode # Shut down application while reloading etc. django-markdownify # Markdown rendering -django-money<3.0.0 # Django app for currency management # FIXED 2022-06-26 to make sure py-moneyed is not conflicting +django-money>=3.0.0 # Django app for currency management django-mptt==0.11.0 # Modified Preorder Tree Traversal django-redis>=5.0.0 # Redis integration django-q2 # Background task scheduling @@ -49,6 +49,3 @@ sentry-sdk # Error reporting (optional) setuptools # Standard dependency tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats weasyprint==54.3 # PDF generation - -# Fixed sub-dependencies -py-moneyed<2.0 # For django-money # FIXED 2022-06-18 as we need `moneyed.localization` diff --git a/requirements.txt b/requirements.txt index 5e58ee03f3..d7c44ea7b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -113,7 +113,7 @@ django-maintenance-mode==0.19.0 # via -r requirements.in django-markdownify==0.9.3 # via -r requirements.in -django-money==2.1.1 +django-money==3.3.0 # via -r requirements.in django-mptt==0.11.0 # via -r requirements.in @@ -211,10 +211,8 @@ pillow==9.5.0 # weasyprint pint==0.21 # via -r requirements.in -py-moneyed==1.2 - # via - # -r requirements.in - # django-money +py-moneyed==3.0 + # via django-money pycparser==2.21 # via cffi pydyf==0.8.0 @@ -302,6 +300,7 @@ tinycss2==1.2.1 typing-extensions==4.8.0 # via # asgiref + # py-moneyed # qrcode uritemplate==4.1.1 # via