From eccd3be1503a5d7247c3046d96ee490ce1ca68e8 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Feb 2023 22:47:35 +1100 Subject: [PATCH] Client side currency conversion (#4293) * Automatically update exchange rates when base currency is updated * Adds API endpoint with currency exchange information * Add unit testing for new endpoint * Implement javascript code for client-side conversion * Adds helper function for calculating total price of a dataset * javascript cleanup * Add functionality to sales order tables * JS linting * Update API version * Prevent auto currency updates under certain conditions --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/common/api.py | 36 +++ InvenTree/common/models.py | 16 +- InvenTree/common/tests.py | 12 + InvenTree/templates/js/translated/helpers.js | 70 ------ InvenTree/templates/js/translated/order.js | 80 +++--- InvenTree/templates/js/translated/pricing.js | 241 +++++++++++++++++++ 7 files changed, 344 insertions(+), 116 deletions(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 778a1657ab..9255b0feaf 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 91 +INVENTREE_API_VERSION = 92 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v92 -> 2023-02-02 : https://github.com/inventree/InvenTree/pull/4293 + - Adds API endpoint for currency exchange information + v91 -> 2023-01-31 : https://github.com/inventree/InvenTree/pull/4281 - Improves the API endpoint for creating new Part instances diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index d17108d3a0..e717ed4c68 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_exempt from django_filters.rest_framework import DjangoFilterBackend from django_q.tasks import async_task +from djmoney.contrib.exchange.models import Rate from rest_framework import filters, permissions, serializers from rest_framework.exceptions import NotAcceptable, NotFound from rest_framework.permissions import IsAdminUser @@ -102,6 +103,36 @@ class WebhookView(CsrfExemptMixin, APIView): raise NotFound() +class CurrencyExchangeView(APIView): + """API endpoint for displaying currency information + + TODO: Add a POST hook to refresh / update the currency exchange data + """ + + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get(self, request, format=None): + """Return information on available currency conversions""" + + # Extract a list of all available rates + try: + rates = Rate.objects.all() + except Exception: + rates = [] + + response = { + 'base_currency': common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', 'USD'), + 'exchange_rates': {} + } + + for rate in rates: + response['exchange_rates'][rate.currency] = rate.value + + return Response(response) + + class SettingsList(ListAPI): """Generic ListView for settings. @@ -418,6 +449,11 @@ common_api_urls = [ # Webhooks path('webhook//', WebhookView.as_view(), name='api-webhook'), + # Currencies + re_path(r'^currency/', include([ + re_path(r'^exchange/', CurrencyExchangeView.as_view(), name='api-currency-exchange'), + ])), + # Notifications re_path(r'^notifications/', include([ # Individual purchase order detail URLs diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 900ab57669..9297430556 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -43,6 +43,7 @@ import build.validators import InvenTree.fields import InvenTree.helpers import InvenTree.ready +import InvenTree.tasks import InvenTree.validators import order.validators @@ -821,6 +822,18 @@ def validate_email_domains(setting): raise ValidationError(_(f'Invalid domain name: {domain}')) +def update_exchange_rates(setting): + """Update exchange rates when base currency is changed""" + + if InvenTree.ready.isImportingData(): + return + + if not InvenTree.ready.canAppAccessDatabase(): + return + + InvenTree.tasks.update_exchange_rates() + + class InvenTreeSetting(BaseInvenTreeSetting): """An InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values). @@ -901,9 +914,10 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'INVENTREE_DEFAULT_CURRENCY': { 'name': _('Default Currency'), - 'description': _('Default currency'), + 'description': _('Select base currency for pricing caluclations'), 'default': 'USD', 'choices': CURRENCY_CHOICES, + 'after_save': update_exchange_rates, }, 'INVENTREE_DOWNLOAD_FROM_URL': { diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 79c127706e..3754fcf9c7 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -900,3 +900,15 @@ class ColorThemeTest(TestCase): # check valid theme self.assertFalse(ColorTheme.is_valid_choice(aa)) self.assertTrue(ColorTheme.is_valid_choice(ab)) + + +class CurrencyAPITests(InvenTreeAPITestCase): + """Unit tests for the currency exchange API endpoints""" + + def test_exchange_endpoint(self): + """Test that the currency exchange endpoint works as expected""" + + response = self.get(reverse('api-currency-exchange'), expected_code=200) + + self.assertIn('base_currency', response.data) + self.assertIn('exchange_rates', response.data) diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 9313bf32e7..4f18f53093 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -4,9 +4,7 @@ blankImage, deleteButton, editButton, - formatCurrency, formatDecimal, - formatPriceRange, imageHoverIcon, makeIconBadge, makeIconButton, @@ -40,74 +38,6 @@ function deleteButton(url, text='{% trans "Delete" %}') { } -/* - * format currency (money) value based on current settings - * - * Options: - * - currency: Currency code (uses default value if none provided) - * - locale: Locale specified (uses default value if none provided) - * - digits: Maximum number of significant digits (default = 10) - */ -function formatCurrency(value, options={}) { - - if (value == null) { - return null; - } - - var digits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6; - - // Strip out any trailing zeros, etc - value = formatDecimal(value, digits); - - // Extract default currency information - var currency = options.currency || global_settings.INVENTREE_DEFAULT_CURRENCY || 'USD'; - - // Exctract locale information - var locale = options.locale || navigator.language || 'en-US'; - - - var formatter = new Intl.NumberFormat( - locale, - { - style: 'currency', - currency: currency, - maximumSignificantDigits: digits, - } - ); - - return formatter.format(value); -} - - -/* - * Format a range of prices - */ -function formatPriceRange(price_min, price_max, options={}) { - - var p_min = price_min || price_max; - var p_max = price_max || price_min; - - var quantity = options.quantity || 1; - - if (p_min == null && p_max == null) { - return null; - } - - p_min = parseFloat(p_min) * quantity; - p_max = parseFloat(p_max) * quantity; - - var output = ''; - - output += formatCurrency(p_min, options); - - if (p_min != p_max) { - output += ' - '; - output += formatCurrency(p_max, options); - } - - return output; -} - /* * Ensure a string does not exceed a maximum length. diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 493b96f789..f1044c020d 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -2396,17 +2396,15 @@ function loadPurchaseOrderLineItemTable(table, options={}) { }); }, footerFormatter: function(data) { - var total = data.map(function(row) { - return +row['purchase_price']*row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); - - var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD'; - - return formatCurrency(total, { - currency: currency - }); + return calculateTotalPrice( + data, + function(row) { + return row.purchase_price ? row.purchase_price * row.quantity : null; + }, + function(row) { + return row.purchase_price_currency; + } + ); } }, { @@ -2583,17 +2581,15 @@ function loadPurchaseOrderExtraLineTable(table, options={}) { }); }, footerFormatter: function(data) { - var total = data.map(function(row) { - return +row['price'] * row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); - - var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD'; - - return formatCurrency(total, { - currency: currency, - }); + return calculateTotalPrice( + data, + function(row) { + return row.price ? row.price * row.quantity : null; + }, + function(row) { + return row.price_currency; + } + ); } } ]; @@ -3908,17 +3904,15 @@ function loadSalesOrderLineItemTable(table, options={}) { }); }, footerFormatter: function(data) { - var total = data.map(function(row) { - return +row['sale_price'] * row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); - - var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD'; - - return formatCurrency(total, { - currency: currency, - }); + return calculateTotalPrice( + data, + function(row) { + return row.sale_price ? row.sale_price * row.quantity : null; + }, + function(row) { + return row.sale_price_currency; + } + ); } }, { @@ -4399,17 +4393,15 @@ function loadSalesOrderExtraLineTable(table, options={}) { }); }, footerFormatter: function(data) { - var total = data.map(function(row) { - return +row['price'] * row['quantity']; - }).reduce(function(sum, i) { - return sum + i; - }, 0); - - var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD'; - - return formatCurrency(total, { - currency: currency, - }); + return calculateTotalPrice( + data, + function(row) { + return row.price ? row.price * row.quantity : null; + }, + function(row) { + return row.price_currency; + } + ); } } ]; diff --git a/InvenTree/templates/js/translated/pricing.js b/InvenTree/templates/js/translated/pricing.js index 66c9f2413f..83102e8204 100644 --- a/InvenTree/templates/js/translated/pricing.js +++ b/InvenTree/templates/js/translated/pricing.js @@ -7,6 +7,11 @@ */ /* exported + baseCurrency, + calculateTotalPrice, + convertCurrency, + formatCurrency, + formatPriceRange, loadBomPricingChart, loadPartSupplierPricingTable, initPriceBreakSet, @@ -17,6 +22,242 @@ */ +/* + * Returns the base currency used for conversion operations + */ +function baseCurrency() { + return global_settings.INVENTREE_BASE_CURRENCY || 'USD'; +} + + + +/* + * format currency (money) value based on current settings + * + * Options: + * - currency: Currency code (uses default value if none provided) + * - locale: Locale specified (uses default value if none provided) + * - digits: Maximum number of significant digits (default = 10) + */ +function formatCurrency(value, options={}) { + + if (value == null) { + return null; + } + + var digits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6; + + // Strip out any trailing zeros, etc + value = formatDecimal(value, digits); + + // Extract default currency information + var currency = options.currency || global_settings.INVENTREE_DEFAULT_CURRENCY || 'USD'; + + // Exctract locale information + var locale = options.locale || navigator.language || 'en-US'; + + + var formatter = new Intl.NumberFormat( + locale, + { + style: 'currency', + currency: currency, + maximumSignificantDigits: digits, + } + ); + + return formatter.format(value); +} + + +/* + * Format a range of prices + */ +function formatPriceRange(price_min, price_max, options={}) { + + var p_min = price_min || price_max; + var p_max = price_max || price_min; + + var quantity = options.quantity || 1; + + if (p_min == null && p_max == null) { + return null; + } + + p_min = parseFloat(p_min) * quantity; + p_max = parseFloat(p_max) * quantity; + + var output = ''; + + output += formatCurrency(p_min, options); + + if (p_min != p_max) { + output += ' - '; + output += formatCurrency(p_max, options); + } + + return output; +} + + +// TODO: Implement a better version of caching here +var cached_exchange_rates = null; + +/* + * Retrieve currency conversion rate information from the server + */ +function getCurrencyConversionRates() { + + if (cached_exchange_rates != null) { + return cached_exchange_rates; + } + + inventreeGet('{% url "api-currency-exchange" %}', {}, { + async: false, + success: function(response) { + cached_exchange_rates = response; + } + }); + + return cached_exchange_rates; +} + + +/* + * Calculate the total price for a given dataset. + * Within each 'row' in the dataset, the 'price' attribute is denoted by 'key' variable + * + * The target currency is determined as follows: + * 1. Provided as options.currency + * 2. All rows use the same currency (defaults to this) + * 3. Use the result of baseCurrency function + */ +function calculateTotalPrice(dataset, value_func, currency_func, options={}) { + + var currency = options.currency; + + var rates = getCurrencyConversionRates(); + + if (!rates) { + console.error('Could not retrieve currency conversion information from the server'); + return ``; + } + + if (!currency) { + // Try to determine currency from the dataset + var common_currency = true; + + for (var idx = 0; idx < dataset.length; idx++) { + var row = dataset[idx]; + + var row_currency = currency_func(row); + + if (row_currency == null) { + continue; + } + + if (currency == null) { + currency = row_currency; + } + + if (currency != row_currency) { + common_currency = false; + break; + } + } + + // Inconsistent currencies between rows - revert to base currency + if (!common_currency) { + currency = baseCurrency(); + } + } + + var total = null; + + for (var ii = 0; ii < dataset.length; ii++) { + var row = dataset[ii]; + + // Pass the row back to the decoder + var value = value_func(row); + + // Ignore null values + if (value == null) { + continue; + } + + // Convert to the desired currency + value = convertCurrency( + value, + currency_func(row) || baseCurrency(), + currency, + rates + ); + + if (value == null) { + continue; + } + + // Total is null until we get a good value + if (total == null) { + total = 0; + } + + total += value; + } + + return formatCurrency(total, { + currency: currency, + }); +} + + +/* + * Convert from one specified currency into another + * + * @param {number} value - numerical value + * @param {string} source_currency - The source currency code e.g. 'AUD' + * @param {string} target_currency - The target currency code e.g. 'USD' + * @param {object} rate_data - Currency exchange rate data received from the server + */ +function convertCurrency(value, source_currency, target_currency, rate_data) { + + if (value == null) { + console.warn('Null value passed to convertCurrency function'); + return null; + } + + // Short circuit the case where the currencies are the same + if (source_currency == target_currency) { + return value; + } + + if (!('base_currency' in rate_data)) { + console.error('Currency data missing base_currency parameter'); + return null; + } + + if (!('exchange_rates' in rate_data)) { + console.error('Currency data missing exchange_rates parameter'); + return null; + } + + var rates = rate_data['exchange_rates']; + + if (!(source_currency in rates)) { + console.error(`Source currency '${source_currency}' not found in exchange rate data`); + return null; + } + + if (!(target_currency in rates)) { + console.error(`Target currency '${target_currency}' not found in exchange rate date`); + return null; + } + + // We assume that the 'base exchange rate' is 1:1 + return value / rates[source_currency] * rates[target_currency]; +} + + /* * Load BOM pricing chart */