Merge pull request #1611 from SchrodingersGat/exchange-simplification

Exchange simplification
This commit is contained in:
Oliver 2021-05-27 17:10:30 +10:00 committed by GitHub
commit 59aca2791a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 192 additions and 316 deletions

View File

@ -1,120 +1,29 @@
from django.conf import settings as inventree_settings
from djmoney.contrib.exchange.backends.base import BaseExchangeBackend
from djmoney.contrib.exchange.models import Rate
from common.models import InvenTreeSetting
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
def get_exchange_rate_backend():
""" Return the exchange rate backend set by user """
custom = InvenTreeSetting.get_setting('CUSTOM_EXCHANGE_RATES', False)
if custom:
return InvenTreeManualExchangeBackend()
else:
return ExchangeRateHostBackend()
class InvenTreeManualExchangeBackend(BaseExchangeBackend):
class InvenTreeExchange(SimpleExchangeBackend):
"""
Backend for manually updating currency exchange rates
Backend for automatically updating currency exchange rates.
See the documentation for django-money: https://github.com/django-money/django-money
Specifically: https://github.com/django-money/django-money/tree/master/djmoney/contrib/exchange/backends
Uses the exchangerate.host service API
"""
name = 'inventree'
url = None
custom_rates = True
base_currency = None
currencies = []
def update_default_currency(self):
""" Update to base currency """
self.base_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', 'USD')
def __init__(self, url=None):
""" Overrides init to update url, base currency and currencies """
self.url = url
self.update_default_currency()
# Update name
self.name = self.name + '-' + self.base_currency.lower()
self.currencies = inventree_settings.CURRENCIES
super().__init__()
def get_rates(self, **kwargs):
""" Returns a mapping <currency>: <rate> """
return kwargs.get('rates', {})
def get_stored_rates(self):
""" Returns stored rate for specified backend and base currency """
stored_rates = {}
stored_rates_obj = Rate.objects.all().prefetch_related('backend')
for rate in stored_rates_obj:
# Find match for backend and base currency
if rate.backend.name == self.name and rate.backend.base_currency == self.base_currency:
# print(f'{rate.currency} | {rate.value} | {rate.backend} | {rate.backend.base_currency}')
stored_rates[rate.currency] = rate.value
return stored_rates
class ExchangeRateHostBackend(InvenTreeManualExchangeBackend):
"""
Backend for https://exchangerate.host/
"""
name = "exchangerate.host"
name = "InvenTreeExchange"
def __init__(self):
self.url = "https://api.exchangerate.host/latest"
self.custom_rates = False
super().__init__(url=self.url)
super().__init__()
def get_params(self):
# No API key is required
return {}
return {
}
def update_rates(self, base_currency=None):
""" Override update_rates method using currencies found in the settings
"""
def update_rates(self, base_currency=inventree_settings.BASE_CURRENCY):
if base_currency:
self.base_currency = base_currency
else:
self.update_default_currency()
symbols = ','.join(self.currencies)
symbols = ','.join(inventree_settings.CURRENCIES)
super().update_rates(base_currency=self.base_currency, symbols=symbols)
def get_rates(self, **params):
""" Returns a mapping <currency>: <rate> """
# Set base currency
params.update(base=self.base_currency)
response = self.get_response(**params)
try:
return self.parse_json(response)['rates']
except KeyError:
# API response did not contain any rate
pass
return {}
super().update_rates(base=base_currency, symbols=symbols)

View File

@ -16,8 +16,6 @@ from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppende
from common.models import ColorTheme
from part.models import PartCategory
from .exchange import InvenTreeManualExchangeBackend
class HelperForm(forms.ModelForm):
""" Provides simple integration of crispy_forms extension. """
@ -240,35 +238,3 @@ class SettingCategorySelectForm(forms.ModelForm):
css_class='row',
),
)
class SettingExchangeRatesForm(forms.Form):
""" Form for displaying and setting currency exchange rates manually """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
exchange_rate_backend = InvenTreeManualExchangeBackend()
# Update default currency (in case it has changed)
exchange_rate_backend.update_default_currency()
for currency in exchange_rate_backend.currencies:
if currency != exchange_rate_backend.base_currency:
# Set field name
field_name = currency
# Set field input box
self.fields[field_name] = forms.CharField(
label=field_name,
required=False,
widget=forms.NumberInput(attrs={
'name': field_name,
'class': 'numberinput',
'style': 'width: 200px;',
'type': 'number',
'min': '0',
'step': 'any',
'value': 0,
})
)

View File

@ -19,6 +19,8 @@ import shutil
import sys
from datetime import datetime
import moneyed
import yaml
from django.utils.translation import gettext_lazy as _
@ -513,11 +515,20 @@ CURRENCIES = CONFIG.get(
],
)
DEFAULT_CURRENCY = get_setting(
'INVENTREE_DEFAULT_CURRENCY',
CONFIG.get('default_currency', 'USD')
# Check that each provided currency is supported
for currency in CURRENCIES:
if currency not in moneyed.CURRENCIES:
print(f"Currency code '{currency}' is not supported")
sys.exit(1)
BASE_CURRENCY = get_setting(
'INVENTREE_BASE_CURRENCY',
CONFIG.get('base_currency', 'USD')
)
# Custom currency exchange backend
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'
# Extract email settings from the config file
email_config = CONFIG.get('email', {})

View File

@ -167,16 +167,16 @@ def update_exchange_rates():
"""
try:
import common.models
from InvenTree.exchange import ExchangeRateHostBackend
from InvenTree.exchange import InvenTreeExchange
from django.conf import settings
except AppRegistryNotReady:
# Apps not yet loaded!
return
backend = ExchangeRateHostBackend()
backend = InvenTreeExchange()
print(f"Updating exchange rates from {backend.url}")
base = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
base = settings.BASE_CURRENCY
print(f"Using base currency '{base}'")

View File

@ -5,6 +5,12 @@ from django.test import TestCase
import django.core.exceptions as django_exceptions
from django.core.exceptions import ValidationError
from django.conf import settings
from djmoney.money import Money
from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from .validators import validate_overage, validate_part_name
from . import helpers
from . import version
@ -13,6 +19,8 @@ from mptt.exceptions import InvalidMove
from decimal import Decimal
import InvenTree.tasks
from stock.models import StockLocation
@ -308,3 +316,46 @@ class TestVersionNumber(TestCase):
self.assertTrue(v_c > v_b)
self.assertTrue(v_d > v_c)
self.assertTrue(v_d > v_a)
class CurrencyTests(TestCase):
"""
Unit tests for currency / exchange rate functionality
"""
def test_rates(self):
# Initially, there will not be any exchange rate information
rates = Rate.objects.all()
self.assertEqual(rates.count(), 0)
# Without rate information, we cannot convert anything...
with self.assertRaises(MissingRate):
convert_money(Money(100, 'USD'), 'AUD')
with self.assertRaises(MissingRate):
convert_money(Money(100, 'AUD'), 'USD')
currencies = settings.CURRENCIES
InvenTree.tasks.update_exchange_rates()
rates = Rate.objects.all()
self.assertEqual(rates.count(), len(currencies))
# Now that we have some exchange rate information, we can perform conversions
# Forwards
convert_money(Money(100, 'USD'), 'AUD')
# Backwards
convert_money(Money(100, 'AUD'), 'USD')
# Convert between non base currencies
convert_money(Money(100, 'CAD'), 'NZD')
# Convert to a symbol which is not covered
with self.assertRaises(MissingRate):
convert_money(Money(100, 'GBP'), 'ZWL')

View File

@ -39,9 +39,9 @@ from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView
from .views import CurrencySettingsView, CurrencyRefreshView
from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView
from .views import CurrencySettingsView
from common.views import SettingEdit
@ -83,15 +83,16 @@ settings_urls = [
url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
url(r'^i18n/?', include('django.conf.urls.i18n')),
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
url(r'^build/?', SettingsView.as_view(template_name='InvenTree/settings/build.html'), name='settings-build'),
url(r'^purchase-order/?', SettingsView.as_view(template_name='InvenTree/settings/po.html'), name='settings-po'),
url(r'^sales-order/?', SettingsView.as_view(template_name='InvenTree/settings/so.html'), name='settings-so'),
url(r'^currencies/?', CurrencySettingsView.as_view(), name='settings-currencies'),
url(r'^global/', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
url(r'^report/', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'),
url(r'^part/', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
url(r'^stock/', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
url(r'^build/', SettingsView.as_view(template_name='InvenTree/settings/build.html'), name='settings-build'),
url(r'^purchase-order/', SettingsView.as_view(template_name='InvenTree/settings/po.html'), name='settings-po'),
url(r'^sales-order/', SettingsView.as_view(template_name='InvenTree/settings/so.html'), name='settings-so'),
url(r'^currencies/', CurrencySettingsView.as_view(), name='settings-currencies'),
url(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),
url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'),

View File

@ -12,24 +12,26 @@ from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.http import JsonResponse, HttpResponseRedirect
from django.urls import reverse_lazy
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.views import View
from django.views.generic import ListView, DetailView, CreateView, FormView, DeleteView, UpdateView
from django.views.generic.base import TemplateView
from django.views.generic.base import RedirectView, TemplateView
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from part.models import Part, PartCategory
from stock.models import StockLocation, StockItem
from common.models import InvenTreeSetting, ColorTheme
from users.models import check_user_role, RuleSet
from InvenTree.helpers import clean_decimal
import InvenTree.tasks
from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .forms import ColorThemeSelectForm, SettingCategorySelectForm
from .forms import SettingExchangeRatesForm
from .helpers import str2bool
from .exchange import get_exchange_rate_backend
from rest_framework import views
@ -772,6 +774,50 @@ class SettingsView(TemplateView):
return ctx
class CurrencyRefreshView(RedirectView):
url = reverse_lazy("settings-currencies")
def post(self, request, *args, **kwargs):
"""
On a POST request we will attempt to refresh the exchange rates
"""
print("POST!")
# Will block for a little bit
InvenTree.tasks.update_exchange_rates()
return self.get(request, *args, **kwargs)
class CurrencySettingsView(TemplateView):
"""
View for configuring currency settings
"""
template_name = "InvenTree/settings/currencies.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs).copy()
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
ctx["base_currency"] = settings.BASE_CURRENCY
ctx["currencies"] = settings.CURRENCIES
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
# When were the rates last updated?
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
ctx["rates_updated"] = backend.last_update
except:
ctx["rates_updated"] = None
return ctx
class AppearanceSelectView(FormView):
""" View for selecting a color theme """
@ -911,89 +957,3 @@ class DatabaseStatsView(AjaxView):
"""
return ctx
class CurrencySettingsView(FormView):
form_class = SettingExchangeRatesForm
template_name = 'InvenTree/settings/currencies.html'
success_url = reverse_lazy('settings-currencies')
exchange_rate_backend = None
def get_exchange_rate_backend(self):
if not self.exchange_rate_backend:
self.exchange_rate_backend = get_exchange_rate_backend()
return self.exchange_rate_backend
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Set default API result
if 'api_rates_success' not in context:
context['default_currency'] = True
else:
# Update form
context['form'] = self.get_form()
# Get exchange rate backend
exchange_rate_backend = self.get_exchange_rate_backend()
context['default_currency'] = exchange_rate_backend.base_currency
context['custom_rates'] = exchange_rate_backend.custom_rates
context['exchange_backend'] = exchange_rate_backend.name
return context
def get_form(self):
form = super().get_form()
# Get exchange rate backend
exchange_rate_backend = self.get_exchange_rate_backend()
# Get stored exchange rates
stored_rates = exchange_rate_backend.get_stored_rates()
for field in form.fields:
if not exchange_rate_backend.custom_rates:
# Disable all the fields
form.fields[field].disabled = True
form.fields[field].initial = clean_decimal(stored_rates.get(field, 0))
return form
def post(self, request, *args, **kwargs):
form = self.get_form()
# Get exchange rate backend
exchange_rate_backend = self.get_exchange_rate_backend()
if not exchange_rate_backend.custom_rates:
# Refresh rate from Fixer.IO API
exchange_rate_backend.update_rates(base_currency=exchange_rate_backend.base_currency)
# Check if rates have been updated
if not exchange_rate_backend.get_stored_rates():
# Update context
context = {'api_rates_success': False}
# Return view with updated context
return self.render_to_response(self.get_context_data(form=form, **context))
else:
# Update rates from form
manual_rates = {}
if form.is_valid():
for field, value in form.cleaned_data.items():
manual_rates[field] = clean_decimal(value)
exchange_rate_backend.update_rates(base_currency=exchange_rate_backend.base_currency, **{'rates': manual_rates})
else:
return self.form_invalid(form)
return self.form_valid(form)

View File

@ -13,6 +13,8 @@ from djmoney.forms.fields import MoneyField
from InvenTree.forms import HelperForm
from InvenTree.helpers import clean_decimal
from common.settings import currency_code_default
from .files import FileManager
from .models import InvenTreeSetting
@ -182,7 +184,7 @@ class MatchItem(forms.Form):
if 'price' in col_guess.lower():
self.fields[field_name] = MoneyField(
label=_(col_guess),
default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'),
default_currency=currency_code_default(),
decimal_places=5,
max_digits=19,
required=False,

View File

@ -14,11 +14,12 @@ from django.db import models, transaction
from django.db.utils import IntegrityError, OperationalError
from django.conf import settings
import djmoney.settings
from djmoney.models.fields import MoneyField
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from common.settings import currency_code_default
from django.utils.translation import ugettext_lazy as _
from django.core.validators import MinValueValidator, URLValidator
from django.core.exceptions import ValidationError
@ -80,20 +81,6 @@ class InvenTreeSetting(models.Model):
'default': '',
},
'INVENTREE_DEFAULT_CURRENCY': {
'name': _('Default Currency'),
'description': _('Default currency'),
'default': 'USD',
'choices': djmoney.settings.CURRENCY_CHOICES,
},
'CUSTOM_EXCHANGE_RATES': {
'name': _('Custom Exchange Rates'),
'description': _('Enable custom exchange rates'),
'validator': bool,
'default': False,
},
'INVENTREE_DOWNLOAD_FROM_URL': {
'name': _('Download from URL'),
'description': _('Allow download of remote images and files from external URL'),
@ -766,7 +753,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
if currency is None:
# Default currency selection
currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
currency = currency_code_default()
pb_min = None
for pb in instance.price_breaks.all():

View File

@ -7,7 +7,8 @@ from __future__ import unicode_literals
from moneyed import CURRENCIES
from common.models import InvenTreeSetting
import common.models
from django.conf import settings
def currency_code_default():
@ -15,7 +16,7 @@ def currency_code_default():
Returns the default currency code (or USD if not specified)
"""
code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
code = settings.BASE_CURRENCY
if code not in CURRENCIES:
code = 'USD'
@ -28,4 +29,4 @@ def stock_expiry_enabled():
Returns True if the stock expiry feature is enabled
"""
return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')
return common.models.InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')

View File

@ -98,20 +98,15 @@ class SettingsViewTest(TestCase):
Tests for a setting which has choices
"""
setting = InvenTreeSetting.get_setting_object('INVENTREE_DEFAULT_CURRENCY')
setting = InvenTreeSetting.get_setting_object('PURCHASEORDER_REFERENCE_PREFIX')
# Default value!
self.assertEqual(setting.value, 'USD')
self.assertEqual(setting.value, 'PO')
url = self.get_url(setting.pk)
# Try posting an invalid currency option
data, errors = self.post(url, {'value': 'XPQaaa'}, valid=False)
self.assertIsNotNone(errors.get('value'), None)
# Try posting a valid currency option
data, errors = self.post(url, {'value': 'AUD'}, valid=True)
data, errors = self.post(url, {'value': 'Purchase Order'}, valid=True)
def test_binary_values(self):
"""

View File

@ -11,9 +11,6 @@ from .models import Company, Contact, ManufacturerPart, SupplierPart
from .models import rename_company_image
from part.models import Part
from InvenTree.exchange import InvenTreeManualExchangeBackend
from djmoney.contrib.exchange.models import Rate
class CompanySimpleTest(TestCase):
@ -40,16 +37,6 @@ class CompanySimpleTest(TestCase):
self.acme0002 = SupplierPart.objects.get(SKU='ACME0002')
self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
# Exchange rate backend
backend = InvenTreeManualExchangeBackend()
backend.update_rates(base_currency=backend.base_currency)
Rate.objects.create(
currency='AUD',
value='1.35',
backend_id=backend.name,
)
def test_company_model(self):
c = Company.objects.get(name='ABC Co.')

View File

@ -52,6 +52,9 @@ language: en-us
# Use the environment variable INVENTREE_TIMEZONE
timezone: UTC
# Base currency code
base_currency: USD
# List of currencies supported by default.
# Add other currencies here to allow use in InvenTree
currencies:

View File

@ -2675,7 +2675,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
initials['part'] = self.get_part()
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
default_currency = settings.BASE_CURRENCY
currency = CURRENCIES.get(default_currency, None)
if currency is not None:

View File

@ -13,40 +13,43 @@
{% block settings %}
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="CUSTOM_EXCHANGE_RATES" icon="fa-edit" %}
<tr>
<th>{% trans "Base Currency" %}</th>
<th>{{ base_currency }}</th>
</tr>
<tr>
<th colspan='2'>{% trans "Exchange Rates" %}</th>
</tr>
{% for rate in rates %}
<tr>
<td>{{ rate.currency }}</td>
<td>{{ rate.value }}</td>
</tr>
{% endfor %}
<tr>
<th>
{% trans "Last Update" %}
</th>
<td>
{% if rates_updated %}
{{ rates_updated }}
{% else %}
<i>{% trans "Never" %}</i>
{% endif %}
<form action='{% url "settings-currencies-refresh" %}' method='post'>
<div id='refresh-rates-form'>
{% csrf_token %}
<button type='submit' id='update-rates' class='btn btn-default float-right'>{% trans "Update Now" %}</button>
</div>
</form>
</td>
</tr>
</tbody>
</table>
<div class='row'>
<div class='col-sm-6'>
<h4>{% blocktrans with cur=default_currency %}Exchange Rates - Convert to {{cur}}{% endblocktrans %}</h4>
</div>
</div>
<form action="{% url 'settings-currencies' %}" method="post">
<div id='exchange_rate_form'>
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
{% if custom_rates is False %}
<button type="submit" class='btn btn-primary'>{% trans "Refresh Exchange Rates" %}</button>
{% else %}
<button type="submit" class='btn btn-primary'>{% trans "Update Exchange Rates" %}</button>
{% endif %}
</div>
</form>
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% if api_rates_success is False %}
var alert_msg = {% blocktrans %}"Failed to refresh exchange rates" {% endblocktrans %};
showAlertOrCache("alert-danger", alert_msg, null, 5000);
{% endif %}
{% endblock %}

View File

@ -15,6 +15,9 @@
<li {% if tab == 'global' %} class='active' {% endif %}>
<a href='{% url "settings-global" %}'><span class='fas fa-globe'></span> {% trans "Global" %}</a>
</li>
<li {% if tab == 'currencies' %} class='active'{% endif %}>
<a href="{% url 'settings-currencies' %}"><span class='fas fa-dollar-sign'></span> {% trans "Currencies" %}</a>
</li>
<li {% if tab == 'report' %} class='active' {% endif %}>
<a href='{% url "settings-report" %}'><span class='fas fa-file-pdf'></span> {% trans "Report" %}</a>
</li>
@ -36,8 +39,5 @@
<li {% if tab == 'so' %} class='active'{% endif %}>
<a href="{% url 'settings-so' %}"><span class='fas fa-truck'></span> {% trans "Sales Orders" %}</a>
</li>
<li {% if tab == 'currencies' %} class='active'{% endif %}>
<a href="{% url 'settings-currencies' %}"><span class='fas fa-dollar-sign'></span> {% trans "Currencies" %}</a>
</li>
</ul>
{% endif %}