diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 06de4861ec..0a75436b1e 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,4 +1,20 @@ +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 + + +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): @@ -10,12 +26,95 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): Specifically: https://github.com/django-money/django-money/tree/master/djmoney/contrib/exchange/backends """ - name = "inventree" + 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): - """ - Do not get any rates... + """ Returns a mapping : """ + + 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" + + def __init__(self): + self.url = "https://api.exchangerate.host/latest" + + self.custom_rates = False + + super().__init__(url=self.url) + + def get_params(self): + # No API key is required + return {} + + def update_rates(self, base_currency=None): + """ Override update_rates method using currencies found in the settings """ + if base_currency: + self.base_currency = base_currency + else: + self.update_default_currency() + + symbols = ','.join(self.currencies) + + super().update_rates(base_currency=self.base_currency, symbols=symbols) + + def get_rates(self, **params): + """ Returns a mapping : """ + + # 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 {} diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 52d1c8758f..488e982ddc 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -7,13 +7,17 @@ from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ from django import forms +from django.contrib.auth.models import User + from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div -from django.contrib.auth.models import User + 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. """ @@ -236,3 +240,35 @@ 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, + }) + ) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 1097c5663b..9d00697230 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -8,7 +8,7 @@ import json import os.path from PIL import Image -from decimal import Decimal +from decimal import Decimal, InvalidOperation from wsgiref.util import FileWrapper from django.http import StreamingHttpResponse @@ -606,3 +606,19 @@ def getNewestMigrationFile(app, exclude_extension=True): newest_file = newest_file.replace('.py', '') return newest_file + + +def clean_decimal(number): + """ Clean-up decimal value """ + + # Check if empty + if number is None or number == '': + return Decimal(0) + + # Check if decimal type + try: + clean_number = Decimal(number) + except InvalidOperation: + clean_number = number + + return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize() diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 5cf0b0c544..afa43396f5 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -513,10 +513,6 @@ CURRENCIES = CONFIG.get( ], ) -BASE_CURRENCY = CONFIG.get('base_currency', 'USD') - -EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend' - # Extract email settings from the config file email_config = CONFIG.get('email', {}) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index b81b4e6de4..365a94fd07 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -163,30 +163,24 @@ def check_for_updates(): def update_exchange_rates(): """ - If an API key for fixer.io has been provided, attempt to update currency exchange rates + Update currency exchange rates """ try: import common.models - from django.conf import settings - from djmoney.contrib.exchange.backends import FixerBackend + from InvenTree.exchange import ExchangeRateHostBackend except AppRegistryNotReady: # Apps not yet loaded! return - fixer_api_key = common.models.InvenTreeSetting.get_setting('INVENTREE_FIXER_API_KEY', '').strip() + backend = ExchangeRateHostBackend() + print(f"Updating exchange rates from {backend.url}") - if not fixer_api_key: - # API key not provided - return + base = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') - backend = FixerBackend(access_key=fixer_api_key) + print(f"Using base currency '{base}'") - currencies = ','.join(settings.CURRENCIES) - - base = settings.BASE_CURRENCY - - backend.update_rates(base_currency=base, symbols=currencies) + backend.update_rates(base_currency=base) def send_email(subject, body, recipients, from_email=None): diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index ab2ced7d5e..d297dc18ad 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -41,6 +41,7 @@ from .views import IndexView, SearchView, DatabaseStatsView from .views import SettingsView, EditUserView, SetPasswordView from .views import AppearanceSelectView, SettingCategorySelectView from .views import DynamicJsView +from .views import CurrencySettingsView from common.views import SettingEdit @@ -90,6 +91,7 @@ settings_urls = [ 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'^(?P\d+)/edit/', SettingEdit.as_view(), name='setting-edit'), diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 361fec152a..67962c7859 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,8 +10,15 @@ import common.models INVENTREE_SW_VERSION = "0.2.2 pre" -# Increment this number whenever there is a significant change to the API that any clients need to know about -INVENTREE_API_VERSION = 2 +""" +Increment thi API version number whenever there is a significant change to the API that any clients need to know about + +v3 -> 2021-05-22: + - The updated StockItem "history tracking" now uses a different interface + +""" + +INVENTREE_API_VERSION = 3 def inventreeInstanceName(): diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index d285efae36..a18845bf02 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -23,10 +23,13 @@ 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 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 @@ -908,3 +911,89 @@ 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) diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 8a0017e38b..bab7ede74c 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -5,14 +5,13 @@ Django forms for interacting with common objects # -*- coding: utf-8 -*- from __future__ import unicode_literals -from decimal import Decimal, InvalidOperation - from django import forms from django.utils.translation import gettext as _ from djmoney.forms.fields import MoneyField from InvenTree.forms import HelperForm +from InvenTree.helpers import clean_decimal from .files import FileManager from .models import InvenTreeSetting @@ -119,21 +118,6 @@ class MatchItem(forms.Form): super().__init__(*args, **kwargs) - def clean(number): - """ Clean-up decimal value """ - - # Check if empty - if not number: - return number - - # Check if decimal type - try: - clean_number = Decimal(number) - except InvalidOperation: - clean_number = number - - return clean_number.quantize(Decimal(1)) if clean_number == clean_number.to_integral() else clean_number.normalize() - # Setup FileManager file_manager.setup() @@ -160,7 +144,7 @@ class MatchItem(forms.Form): 'type': 'number', 'min': '0', 'step': 'any', - 'value': clean(row.get('quantity', '')), + 'value': clean_decimal(row.get('quantity', '')), }) ) @@ -202,7 +186,7 @@ class MatchItem(forms.Form): decimal_places=5, max_digits=19, required=False, - default_amount=clean(value), + default_amount=clean_decimal(value), ) else: self.fields[field_name] = forms.CharField( diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index e499e9b801..74c6c82b41 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -87,10 +87,11 @@ class InvenTreeSetting(models.Model): 'choices': djmoney.settings.CURRENCY_CHOICES, }, - 'INVENTREE_FIXER_API_KEY': { - 'name': _('fixer.io API key'), - 'description': _('API key for fixer.io currency conversion service'), - 'default': '', + 'CUSTOM_EXCHANGE_RATES': { + 'name': _('Custom Exchange Rates'), + 'description': _('Enable custom exchange rates'), + 'validator': bool, + 'default': False, }, 'INVENTREE_DOWNLOAD_FROM_URL': { diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index 5dd3bf81ab..2c6e722440 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -40,13 +40,15 @@ class CompanySimpleTest(TestCase): self.acme0002 = SupplierPart.objects.get(SKU='ACME0002') self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS') self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312') - - InvenTreeManualExchangeBackend().update_rates() + + # Exchange rate backend + backend = InvenTreeManualExchangeBackend() + backend.update_rates(base_currency=backend.base_currency) Rate.objects.create( currency='AUD', value='1.35', - backend_id='inventree', + backend_id=backend.name, ) def test_company_model(self): diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 734b365447..ceb2d11e31 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -119,6 +119,12 @@ def inventree_version(*args, **kwargs): return version.inventreeVersion() +@register.simple_tag() +def inventree_api_version(*args, **kwargs): + """ Return InvenTree API version """ + return version.inventreeApiVersion() + + @register.simple_tag() def django_version(*args, **kwargs): """ Return Django version string """ diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html new file mode 100644 index 0000000000..dd47bc6cdd --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -0,0 +1,52 @@ +{% extends "InvenTree/settings/settings.html" %} +{% load i18n %} +{% load inventree_extras %} + +{% block tabs %} +{% include "InvenTree/settings/tabs.html" with tab='currencies' %} +{% endblock %} + +{% block subtitle %} +{% trans "Currency Settings" %} +{% endblock %} + +{% block settings %} + + + {% include "InvenTree/settings/header.html" %} + + {% 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" %} + +
+ +
+
+

{% blocktrans with cur=default_currency %}Exchange Rates - Convert to {{cur}}{% endblocktrans %}

+
+
+ +
+
+ {% csrf_token %} + {% load crispy_forms_tags %} + {% crispy form %} + {% if custom_rates is False %} + + {% else %} + + {% endif %} +
+
+ +{% 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 %} \ No newline at end of file diff --git a/InvenTree/templates/InvenTree/settings/global.html b/InvenTree/templates/InvenTree/settings/global.html index 5c5dccfb2a..1a8915f4fb 100644 --- a/InvenTree/templates/InvenTree/settings/global.html +++ b/InvenTree/templates/InvenTree/settings/global.html @@ -19,8 +19,6 @@ {% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE_TITLE" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %} - {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} - {% include "InvenTree/settings/setting.html" with key="INVENTREE_FIXER_API_KEY" icon="fa-key" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %} @@ -33,4 +31,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/InvenTree/templates/InvenTree/settings/tabs.html b/InvenTree/templates/InvenTree/settings/tabs.html index 3f8be0a313..360618fc34 100644 --- a/InvenTree/templates/InvenTree/settings/tabs.html +++ b/InvenTree/templates/InvenTree/settings/tabs.html @@ -36,5 +36,8 @@
  • {% trans "Sales Orders" %}
  • +
  • + {% trans "Currencies" %} +
  • {% endif %} diff --git a/InvenTree/templates/about.html b/InvenTree/templates/about.html index f190ab2755..7b023295d1 100644 --- a/InvenTree/templates/about.html +++ b/InvenTree/templates/about.html @@ -29,6 +29,11 @@ {% endif %} + + + {% trans "API Version" %} + {% inventree_api_version %}{% include "clip.html" %} + {% trans "Django Version" %} diff --git a/docker/Dockerfile b/docker/Dockerfile index ea70d9f994..5c5d1dc32a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -41,9 +41,10 @@ LABEL org.label-schema.schema-version="1.0" \ # Create user account RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup + WORKDIR ${INVENTREE_HOME} -RUN mkdir ${INVENTREE_STATIC_ROOT} +RUN mkdir -p ${INVENTREE_STATIC_ROOT} # Install required system packages RUN apk add --no-cache git make bash \ diff --git a/docker/start_dev_server.sh b/docker/start_dev_server.sh index 0c1564076a..d4e33a79a5 100644 --- a/docker/start_dev_server.sh +++ b/docker/start_dev_server.sh @@ -3,12 +3,12 @@ # Create required directory structure (if it does not already exist) if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then echo "Creating directory $INVENTREE_STATIC_ROOT" - mkdir $INVENTREE_STATIC_ROOT + mkdir -p $INVENTREE_STATIC_ROOT fi if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then echo "Creating directory $INVENTREE_MEDIA_ROOT" - mkdir $INVENTREE_MEDIA_ROOT + mkdir -p $INVENTREE_MEDIA_ROOT fi # Check if "config.yaml" has been copied into the correct location diff --git a/docker/start_prod_server.sh b/docker/start_prod_server.sh index 2767e844d6..2e5acb5c9d 100644 --- a/docker/start_prod_server.sh +++ b/docker/start_prod_server.sh @@ -3,12 +3,12 @@ # Create required directory structure (if it does not already exist) if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then echo "Creating directory $INVENTREE_STATIC_ROOT" - mkdir $INVENTREE_STATIC_ROOT + mkdir -p $INVENTREE_STATIC_ROOT fi if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then echo "Creating directory $INVENTREE_MEDIA_ROOT" - mkdir $INVENTREE_MEDIA_ROOT + mkdir -p $INVENTREE_MEDIA_ROOT fi # Check if "config.yaml" has been copied into the correct location