diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index ad5f7a841e..6d0334b804 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -2,7 +2,14 @@ name: SQLite -on: ["push", "pull_request"] +on: + push: + branches-ignore: + - l10* + + pull_request: + branches-ignore: + - l10* jobs: diff --git a/.github/workflows/mysql.yaml b/.github/workflows/mysql.yaml index 5bafe56253..f0eb0efd7f 100644 --- a/.github/workflows/mysql.yaml +++ b/.github/workflows/mysql.yaml @@ -2,7 +2,14 @@ name: MySQL -on: ["push", "pull_request"] +on: + push: + branches-ignore: + - l10* + + pull_request: + branches-ignore: + - l10* jobs: diff --git a/.github/workflows/postgresql.yaml b/.github/workflows/postgresql.yaml index 3481895d85..9a56382c4e 100644 --- a/.github/workflows/postgresql.yaml +++ b/.github/workflows/postgresql.yaml @@ -2,7 +2,14 @@ name: PostgreSQL -on: ["push", "pull_request"] +on: + push: + branches-ignore: + - l10* + + pull_request: + branches-ignore: + - l10* jobs: diff --git a/.github/workflows/style.yaml b/.github/workflows/style.yaml index 31da3ec61a..df52de1dcb 100644 --- a/.github/workflows/style.yaml +++ b/.github/workflows/style.yaml @@ -1,6 +1,13 @@ name: Style Checks -on: ["push", "pull_request"] +on: + push: + branches-ignore: + - l10* + + pull_request: + branches-ignore: + - l10* jobs: style: diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index a3950b2002..24106c028e 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -5,7 +5,6 @@ on: branches: - master - jobs: build: diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index aa60058dcf..aeddb714a0 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -4,8 +4,9 @@ import logging from django.apps import AppConfig from django.core.exceptions import AppRegistryNotReady +from django.conf import settings -from InvenTree.ready import canAppAccessDatabase +from InvenTree.ready import isInTestMode, canAppAccessDatabase import InvenTree.tasks @@ -20,6 +21,9 @@ class InvenTreeConfig(AppConfig): if canAppAccessDatabase(): self.start_background_tasks() + if not isInTestMode(): + self.update_exchange_rates() + def start_background_tasks(self): try: @@ -49,3 +53,53 @@ class InvenTreeConfig(AppConfig): 'InvenTree.tasks.update_exchange_rates', schedule_type=Schedule.DAILY, ) + + def update_exchange_rates(self): + """ + Update exchange rates each time the server is started, *if*: + + a) Have not been updated recently (one day or less) + b) The base exchange rate has been altered + """ + + try: + from djmoney.contrib.exchange.models import ExchangeBackend + from datetime import datetime, timedelta + from InvenTree.tasks import update_exchange_rates + except AppRegistryNotReady: + pass + + base_currency = settings.BASE_CURRENCY + + update = False + + try: + backend = ExchangeBackend.objects.get(name='InvenTreeExchange') + + last_update = backend.last_update + + if last_update is not None: + delta = datetime.now().date() - last_update.date() + if delta > timedelta(days=1): + print(f"Last update was {last_update}") + update = True + else: + # Never been updated + print("Exchange backend has never been updated") + update = True + + # Backend currency has changed? + if not base_currency == backend.base_currency: + print(f"Base currency changed from {backend.base_currency} to {base_currency}") + update = True + + except (ExchangeBackend.DoesNotExist): + print("Exchange backend not found - updating") + update = True + + except: + # Some other error - potentially the tables are not ready yet + return + + if update: + update_exchange_rates() diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 06de4861ec..0695e69f48 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,21 +1,29 @@ -from djmoney.contrib.exchange.backends.base import BaseExchangeBackend +from django.conf import settings as inventree_settings + +from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend -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 + name = "InvenTreeExchange" - def get_rates(self, **kwargs): - """ - Do not get any rates... - """ + def __init__(self): + self.url = "https://api.exchangerate.host/latest" - return {} + super().__init__() + + def get_params(self): + # No API key is required + return { + } + + def update_rates(self, base_currency=inventree_settings.BASE_CURRENCY): + + symbols = ','.join(inventree_settings.CURRENCIES) + + super().update_rates(base=base_currency, symbols=symbols) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 52d1c8758f..d843c1ddef 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -7,10 +7,12 @@ 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 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/ready.py b/InvenTree/InvenTree/ready.py index aa31fac947..5a4f1e9576 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -1,6 +1,17 @@ import sys +def isInTestMode(): + """ + Returns True if the database is in testing mode + """ + + if 'test' in sys.argv: + return True + + return False + + def canAppAccessDatabase(): """ Returns True if the apps.py file can access database records. diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 5cf0b0c544..618c9f730f 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -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,9 +515,19 @@ CURRENCIES = CONFIG.get( ], ) -BASE_CURRENCY = CONFIG.get('base_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) -EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend' +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', {}) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index b81b4e6de4..d45df99152 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -163,30 +163,41 @@ 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 InvenTree.exchange import InvenTreeExchange + from djmoney.contrib.exchange.models import ExchangeBackend, Rate from django.conf import settings - from djmoney.contrib.exchange.backends import FixerBackend except AppRegistryNotReady: # Apps not yet loaded! return - - fixer_api_key = common.models.InvenTreeSetting.get_setting('INVENTREE_FIXER_API_KEY', '').strip() - - if not fixer_api_key: - # API key not provided + except: + # Other error? return - backend = FixerBackend(access_key=fixer_api_key) + # Test to see if the database is ready yet + try: + backend = ExchangeBackend.objects.get(name='InvenTreeExchange') + except ExchangeBackend.DoesNotExist: + pass + except: + # Some other error + print("Database not ready") + return - currencies = ','.join(settings.CURRENCIES) + backend = InvenTreeExchange() + print(f"Updating exchange rates from {backend.url}") base = settings.BASE_CURRENCY - backend.update_rates(base_currency=base, symbols=currencies) + print(f"Using base currency '{base}'") + + backend.update_rates(base_currency=base) + + # Remove any exchange rates which are not in the provided currencies + Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=settings.CURRENCIES).delete() def send_email(subject, body, recipients, from_email=None): 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') diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index ab2ced7d5e..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,14 +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'^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 d285efae36..108908c571 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -12,18 +12,23 @@ 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 +import InvenTree.tasks + from .forms import DeleteForm, EditUserForm, SetPasswordForm from .forms import ColorThemeSelectForm, SettingCategorySelectForm from .helpers import str2bool @@ -769,6 +774,51 @@ class SettingsView(TemplateView): return ctx +class CurrencyRefreshView(RedirectView): + """ + POST endpoint to refresh / update exchange rates + """ + + url = reverse_lazy("settings-currencies") + + def post(self, request, *args, **kwargs): + """ + On a POST request we will attempt to refresh the exchange rates + """ + + # 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 """ diff --git a/InvenTree/common/forms.py b/InvenTree/common/forms.py index 8a0017e38b..f161c8cc01 100644 --- a/InvenTree/common/forms.py +++ b/InvenTree/common/forms.py @@ -5,14 +5,15 @@ 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 common.settings import currency_code_default from .files import FileManager from .models import InvenTreeSetting @@ -119,21 +120,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 +146,7 @@ class MatchItem(forms.Form): 'type': 'number', 'min': '0', 'step': 'any', - 'value': clean(row.get('quantity', '')), + 'value': clean_decimal(row.get('quantity', '')), }) ) @@ -198,11 +184,11 @@ 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, - 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..236e48770f 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -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,19 +81,6 @@ class InvenTreeSetting(models.Model): 'default': '', }, - 'INVENTREE_DEFAULT_CURRENCY': { - 'name': _('Default Currency'), - 'description': _('Default currency'), - 'default': 'USD', - 'choices': djmoney.settings.CURRENCY_CHOICES, - }, - - 'INVENTREE_FIXER_API_KEY': { - 'name': _('fixer.io API key'), - 'description': _('API key for fixer.io currency conversion service'), - 'default': '', - }, - 'INVENTREE_DOWNLOAD_FROM_URL': { 'name': _('Download from URL'), 'description': _('Allow download of remote images and files from external URL'), @@ -765,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(): diff --git a/InvenTree/common/settings.py b/InvenTree/common/settings.py index 4d98bc495b..60265f4cb9 100644 --- a/InvenTree/common/settings.py +++ b/InvenTree/common/settings.py @@ -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') 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 5dd3bf81ab..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): @@ -41,14 +38,6 @@ class CompanySimpleTest(TestCase): self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS') self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312') - InvenTreeManualExchangeBackend().update_rates() - - Rate.objects.create( - currency='AUD', - value='1.35', - backend_id='inventree', - ) - def test_company_model(self): c = Company.objects.get(name='ABC Co.') self.assertEqual(c.name, 'ABC Co.') diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 87dfb6b545..1333b876b8 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -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: diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index de6ac5f273..5bdd572145 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -7,7 +7,7 @@ from __future__ import unicode_literals from django_filters.rest_framework import DjangoFilterBackend from django.http import JsonResponse -from django.db.models import Q, F, Count +from django.db.models import Q, F, Count, Min, Max, Avg from django.utils.translation import ugettext_lazy as _ from rest_framework import status @@ -15,6 +15,10 @@ from rest_framework.response import Response from rest_framework import filters, serializers from rest_framework import generics +from djmoney.money import Money +from djmoney.contrib.exchange.models import convert_money +from djmoney.contrib.exchange.exceptions import MissingRate + from django.conf.urls import url, include from django.urls import reverse @@ -24,6 +28,7 @@ from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak from .models import PartCategoryParameterTemplate +from common.models import InvenTreeSetting from build.models import Build from . import serializers as part_serializers @@ -877,6 +882,60 @@ class BomList(generics.ListCreateAPIView): else: queryset = queryset.exclude(pk__in=pks) + # Annotate with purchase prices + queryset = queryset.annotate( + purchase_price_min=Min('sub_part__stock_items__purchase_price'), + purchase_price_max=Max('sub_part__stock_items__purchase_price'), + purchase_price_avg=Avg('sub_part__stock_items__purchase_price'), + ) + + # Get values for currencies + currencies = queryset.annotate( + purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'), + ).values('pk', 'sub_part', 'purchase_price_currency') + + def convert_price(price, currency, decimal_places=4): + """ Convert price field, returns Money field """ + + price_adjusted = None + + # Get default currency from settings + default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') + + if price: + if currency and default_currency: + try: + # Get adjusted price + price_adjusted = convert_money(Money(price, currency), default_currency) + except MissingRate: + # No conversion rate set + price_adjusted = Money(price, currency) + else: + # Currency exists + if currency: + price_adjusted = Money(price, currency) + # Default currency exists + if default_currency: + price_adjusted = Money(price, default_currency) + + if price_adjusted and decimal_places: + price_adjusted.decimal_places = decimal_places + + return price_adjusted + + # Convert prices to default currency (using backend conversion rates) + for bom_item in queryset: + # Find associated currency (select first found) + purchase_price_currency = None + for currency_item in currencies: + if currency_item['pk'] == bom_item.pk and currency_item['sub_part'] == bom_item.sub_part.pk: + purchase_price_currency = currency_item['purchase_price_currency'] + break + # Convert prices + bom_item.purchase_price_min = convert_price(bom_item.purchase_price_min, purchase_price_currency) + bom_item.purchase_price_max = convert_price(bom_item.purchase_price_max, purchase_price_currency) + bom_item.purchase_price_avg = convert_price(bom_item.purchase_price_avg, purchase_price_currency) + return queryset filter_backends = [ 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/serializers.py b/InvenTree/part/serializers.py index 7ab385249c..04e0b7a119 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -12,6 +12,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField, from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum +from djmoney.contrib.django_rest_framework import MoneyField from stock.models import StockItem from .models import (BomItem, Part, PartAttachment, PartCategory, @@ -367,6 +368,14 @@ class BomItemSerializer(InvenTreeModelSerializer): validated = serializers.BooleanField(read_only=True, source='is_line_valid') + purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True) + + purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True) + + purchase_price_avg = serializers.SerializerMethodField() + + purchase_price_range = serializers.SerializerMethodField() + def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. # This saves a bunch of database requests @@ -394,6 +403,53 @@ class BomItemSerializer(InvenTreeModelSerializer): queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks') return queryset + def get_purchase_price_range(self, obj): + """ Return purchase price range """ + + try: + purchase_price_min = obj.purchase_price_min + except AttributeError: + return None + + try: + purchase_price_max = obj.purchase_price_max + except AttributeError: + return None + + if purchase_price_min and not purchase_price_max: + # Get price range + purchase_price_range = str(purchase_price_max) + elif not purchase_price_min and purchase_price_max: + # Get price range + purchase_price_range = str(purchase_price_max) + elif purchase_price_min and purchase_price_max: + # Get price range + if purchase_price_min >= purchase_price_max: + # If min > max: use min only + purchase_price_range = str(purchase_price_min) + else: + purchase_price_range = str(purchase_price_min) + " - " + str(purchase_price_max) + else: + purchase_price_range = '-' + + return purchase_price_range + + def get_purchase_price_avg(self, obj): + """ Return purchase price average """ + + try: + purchase_price_avg = obj.purchase_price_avg + except AttributeError: + return None + + if purchase_price_avg: + # Get string representation of price average + purchase_price_avg = str(purchase_price_avg) + else: + purchase_price_avg = '-' + + return purchase_price_avg + class Meta: model = BomItem fields = [ @@ -410,6 +466,10 @@ class BomItemSerializer(InvenTreeModelSerializer): 'sub_part_detail', # 'price_range', 'validated', + 'purchase_price_min', + 'purchase_price_max', + 'purchase_price_avg', + 'purchase_price_range', ] 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/part/views.py b/InvenTree/part/views.py index 24fc73fd3e..b4f33c9382 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -2764,7 +2764,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: diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index da770bab48..fa91177bf6 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -139,7 +139,7 @@