From c2fe5e08b4bb52277050309d98596f275b5ef040 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 27 May 2021 12:35:55 +1000 Subject: [PATCH 1/8] Expand possibilities for variant conversion - Ref get_conversion_options --- InvenTree/part/models.py | 53 +++++++++++++++++++ InvenTree/part/templates/part/part_base.html | 5 ++ .../stock/templates/stock/item_base.html | 2 +- InvenTree/stock/views.py | 2 +- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 3c8e65bca7..7db998ab3d 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1861,6 +1861,59 @@ class Part(MPTTModel): return self.get_descendants(include_self=False) + @property + def can_convert(self): + """ + Check if this Part can be "converted" to a different variant: + + It can be converted if: + + a) It has non-virtual variant parts underneath it + b) It has non-virtual template parts above it + c) It has non-virtual sibling variants + + """ + + return self.get_conversion_options().count() > 0 + + def get_conversion_options(self): + """ + Return options for converting this part to a "variant" within the same tree + + a) Variants underneath this one + b) Immediate parent + c) Siblings + """ + + parts = [] + + # Child parts + children = self.get_descendants(include_self=False) + + for child in children: + parts.append(child) + + # Immediate parent + if self.variant_of: + parts.append(self.variant_of) + + siblings = self.get_siblings(include_self=False) + + for sib in siblings: + parts.append(sib) + + filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts]) + + # Ensure this part is not in the queryset, somehow + filtered_parts = filtered_parts.exclude(pk=self.pk) + + filtered_parts = filtered_parts.filter( + active=True, + virtual=False, + ) + + return filtered_parts + def get_related_parts(self): """ Return list of tuples for all related parts: - first value is PartRelated object diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index f2f1f6557a..ec296d4174 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -102,6 +102,11 @@
+ {% if part.virtual %} +
+ {% trans "This is a virtual part" %} +
+ {% endif %} {% if part.variant_of %}
{% object_link 'part-variants' part.variant_of.id part.variant_of.full_name as link %} diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index da770bab48..991bdee41c 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -139,7 +139,7 @@
{% endif %} From 608547867290cbc29c47372580948b3cc15e5572 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 27 May 2021 16:34:37 +1000 Subject: [PATCH 6/8] Simplify settings view - Show various currency exchange rates - Button to "refresh now" --- InvenTree/InvenTree/forms.py | 34 ----- InvenTree/InvenTree/urls.py | 20 +-- InvenTree/InvenTree/views.py | 139 +++++++----------- .../InvenTree/settings/currencies.html | 59 ++++---- 4 files changed, 92 insertions(+), 160 deletions(-) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 488e982ddc..d843c1ddef 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -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, - }) - ) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 88ee554203..bce493fb23 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -39,6 +39,7 @@ 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 @@ -82,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/?', SettingsView.as_view(template_name='InvenTree/settings/currencies.html'), 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\d+)/edit/', SettingEdit.as_view(), name='setting-edit'), diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index a18845bf02..4189def492 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -12,12 +12,15 @@ 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 @@ -25,11 +28,11 @@ 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 +775,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 +958,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) diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index dd47bc6cdd..78598236f9 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -13,40 +13,43 @@ {% 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" %} + + + + + + + + {% for rate in rates %} + + + + + {% endfor %} + + + +
{% trans "Base Currency" %}{{ base_currency }}
{% trans "Exchange Rates" %}
{{ rate.currency }}{{ rate.value }}
+ {% trans "Last Update" %} + + {% if rates_updated %} + {{ rates_updated }} + {% else %} + {% trans "Never" %} + {% endif %} +
+
+ {% csrf_token %} + +
+
+
-
-
-

{% 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 From 4520bb74478258b0842bb2af69658cbb99d6884b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 27 May 2021 16:36:26 +1000 Subject: [PATCH 7/8] PEP style fixes --- InvenTree/InvenTree/exchange.py | 2 +- InvenTree/InvenTree/views.py | 3 +-- InvenTree/common/models.py | 1 - InvenTree/common/test_views.py | 11 +++-------- InvenTree/company/tests.py | 13 ------------- 5 files changed, 5 insertions(+), 25 deletions(-) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index dfbfff872c..0695e69f48 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -7,7 +7,7 @@ class InvenTreeExchange(SimpleExchangeBackend): """ Backend for automatically updating currency exchange rates. - Uses the exchangerate.host service API + Uses the exchangerate.host service API """ name = "InvenTreeExchange" diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 4189def492..4ac90bd722 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -26,7 +26,6 @@ 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 @@ -775,7 +774,6 @@ class SettingsView(TemplateView): return ctx - class CurrencyRefreshView(RedirectView): url = reverse_lazy("settings-currencies") @@ -819,6 +817,7 @@ class CurrencySettingsView(TemplateView): return ctx + class AppearanceSelectView(FormView): """ View for selecting a color theme """ diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 6cfe5915e0..236e48770f 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -14,7 +14,6 @@ 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 diff --git a/InvenTree/common/test_views.py b/InvenTree/common/test_views.py index 8dc5830108..56a244ba0c 100644 --- a/InvenTree/common/test_views.py +++ b/InvenTree/common/test_views.py @@ -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): """ diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index 2c6e722440..b1e05efe14 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -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.') From c71f4ed04577ac5450be7993e322db74bf054c89 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 27 May 2021 16:48:13 +1000 Subject: [PATCH 8/8] Add currency exchange unit tests --- InvenTree/InvenTree/tests.py | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index d65829cf8e..b7e5b98c1b 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -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')