From f2e9f58f1b3916f058e42fcbfa29c3b3e0539b41 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 13 May 2021 15:47:42 -0400 Subject: [PATCH 01/37] Added purchase price range and average to BOM items/view --- InvenTree/part/api.py | 9 ++++++++- InvenTree/part/serializers.py | 9 +++++++++ InvenTree/templates/js/bom.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index de6ac5f273..659976a0b0 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 @@ -877,6 +877,13 @@ 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'), + ) + return queryset filter_backends = [ diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 7ab385249c..8c2d72f43d 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -367,6 +367,12 @@ class BomItemSerializer(InvenTreeModelSerializer): validated = serializers.BooleanField(read_only=True, source='is_line_valid') + purchase_price_min = serializers.FloatField() + + purchase_price_max = serializers.FloatField() + + purchase_price_avg = serializers.FloatField() + def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. # This saves a bunch of database requests @@ -410,6 +416,9 @@ class BomItemSerializer(InvenTreeModelSerializer): 'sub_part_detail', # 'price_range', 'validated', + 'purchase_price_min', + 'purchase_price_max', + 'purchase_price_avg', ] diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index 462db6eba4..c97d8b30e5 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -243,6 +243,35 @@ function loadBomTable(table, options) { } }); + cols.push( + { + field: 'purchase_price_range', + title: '{% trans "Purchase Price Range" %}', + searchable: false, + sortable: true, + formatter: function(value, row, index, field) { + var purchase_price_range = 0; + + if (row.purchase_price_min > 0) { + if (row.purchase_price_min >= row.purchase_price_max) { + purchase_price_range = row.purchase_price_min; + } else { + purchase_price_range = row.purchase_price_min + " - " + row.purchase_price_max; + } + } + + return purchase_price_range; + }, + }); + + cols.push( + { + field: 'purchase_price_avg', + title: '{% trans "Purchase Price Average" %}', + searchable: false, + sortable: true, + }); + /* // TODO - Re-introduce the pricing column at a later stage, From 32d0f3039de910b8e3c90164c5c80da84907e541 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 13 May 2021 16:17:45 -0400 Subject: [PATCH 02/37] Obviously new float fields should be read-only... --- InvenTree/part/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 8c2d72f43d..2316f2f18b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -367,11 +367,11 @@ class BomItemSerializer(InvenTreeModelSerializer): validated = serializers.BooleanField(read_only=True, source='is_line_valid') - purchase_price_min = serializers.FloatField() + purchase_price_min = serializers.FloatField(read_only=True) - purchase_price_max = serializers.FloatField() + purchase_price_max = serializers.FloatField(read_only=True) - purchase_price_avg = serializers.FloatField() + purchase_price_avg = serializers.FloatField(read_only=True) def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. From 68f5ec8b6a9f54e8273627e2495edd7fbb169179 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 13 May 2021 17:09:52 -0400 Subject: [PATCH 03/37] Added currency conversion --- InvenTree/part/api.py | 42 +++++++++++++++++++++++++++++++++++ InvenTree/part/serializers.py | 7 +++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 659976a0b0..5b82a168cf 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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 @@ -882,8 +887,45 @@ class BomList(generics.ListCreateAPIView): 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'), + purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'), ) + # Convert prices to default currency (using backend conversion rates) + for item in queryset: + # Get default currency from settings + default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') + + if default_currency: + if item.purchase_price_min: + # Convert minimum + try: + # Get adjusted price + purchase_price_adjusted = convert_money(Money(item.purchase_price_min, item.purchase_price_currency), default_currency) + # Update queryset + item.purchase_price_min = purchase_price_adjusted + except MissingRate: + pass + + if item.purchase_price_max: + # Convert maximum + try: + # Get adjusted price + purchase_price_adjusted = convert_money(Money(item.purchase_price_max, item.purchase_price_currency), default_currency) + # Update queryset + item.purchase_price_max = purchase_price_adjusted + except MissingRate: + pass + + if item.purchase_price_avg: + # Convert average + try: + # Get adjusted price + purchase_price_adjusted = convert_money(Money(item.purchase_price_avg, item.purchase_price_currency), default_currency) + # Update queryset + item.purchase_price_avg = purchase_price_adjusted + except MissingRate: + pass + return queryset filter_backends = [ diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 2316f2f18b..096d072981 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,11 +368,11 @@ class BomItemSerializer(InvenTreeModelSerializer): validated = serializers.BooleanField(read_only=True, source='is_line_valid') - purchase_price_min = serializers.FloatField(read_only=True) + purchase_price_min = MoneyField(max_digits=10, decimal_places=4, read_only=True) - purchase_price_max = serializers.FloatField(read_only=True) + purchase_price_max = MoneyField(max_digits=10, decimal_places=4, read_only=True) - purchase_price_avg = serializers.FloatField(read_only=True) + purchase_price_avg = MoneyField(max_digits=10, decimal_places=4, read_only=True) def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. From 1940fd5199f34e91b6df6b17dc8a8adbd909efbf Mon Sep 17 00:00:00 2001 From: eeintech Date: Fri, 14 May 2021 16:16:23 -0400 Subject: [PATCH 04/37] Now processing currencies --- InvenTree/part/api.py | 70 ++++++++++++++++++++--------------- InvenTree/part/serializers.py | 43 +++++++++++++++++++-- InvenTree/templates/js/bom.js | 13 ------- 3 files changed, 79 insertions(+), 47 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 5b82a168cf..065eca30c6 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -887,44 +887,54 @@ class BomList(generics.ListCreateAPIView): 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'), - purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'), ) - # Convert prices to default currency (using backend conversion rates) - for item in queryset: + # 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 default_currency: - if item.purchase_price_min: - # Convert minimum + if price: + if currency and default_currency: try: # Get adjusted price - purchase_price_adjusted = convert_money(Money(item.purchase_price_min, item.purchase_price_currency), default_currency) - # Update queryset - item.purchase_price_min = purchase_price_adjusted + price_adjusted = convert_money(Money(price, currency), default_currency) except MissingRate: - pass - - if item.purchase_price_max: - # Convert maximum - try: - # Get adjusted price - purchase_price_adjusted = convert_money(Money(item.purchase_price_max, item.purchase_price_currency), default_currency) - # Update queryset - item.purchase_price_max = purchase_price_adjusted - except MissingRate: - pass - - if item.purchase_price_avg: - # Convert average - try: - # Get adjusted price - purchase_price_adjusted = convert_money(Money(item.purchase_price_avg, item.purchase_price_currency), default_currency) - # Update queryset - item.purchase_price_avg = purchase_price_adjusted - except MissingRate: - pass + # 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: + 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 diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 096d072981..868997915b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -368,11 +368,13 @@ class BomItemSerializer(InvenTreeModelSerializer): validated = serializers.BooleanField(read_only=True, source='is_line_valid') - purchase_price_min = MoneyField(max_digits=10, decimal_places=4, read_only=True) + purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True) - purchase_price_max = MoneyField(max_digits=10, decimal_places=4, read_only=True) - - purchase_price_avg = MoneyField(max_digits=10, decimal_places=4, 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. @@ -401,6 +403,38 @@ 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 """ + + if obj.purchase_price_min and not obj.purchase_price_max: + # Get price range + purchase_price_range = str(obj.purchase_price_max) + elif not obj.purchase_price_min and obj.purchase_price_max: + # Get price range + purchase_price_range = str(obj.purchase_price_max) + elif obj.purchase_price_min and obj.purchase_price_max: + # Get price range + if obj.purchase_price_min >= obj.purchase_price_max: + # If min > max: use min only + purchase_price_range = str(obj.purchase_price_min) + else: + purchase_price_range = str(obj.purchase_price_min) + " - " + str(obj.purchase_price_max) + else: + purchase_price_range = '-' + + return purchase_price_range + + def get_purchase_price_avg(self, obj): + """ Return purchase price average """ + + if obj.purchase_price_avg: + # Get string representation of price average + purchase_price_avg = str(obj.purchase_price_avg) + else: + purchase_price_avg = '-' + + return purchase_price_avg + class Meta: model = BomItem fields = [ @@ -420,6 +454,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'purchase_price_min', 'purchase_price_max', 'purchase_price_avg', + 'purchase_price_range', ] diff --git a/InvenTree/templates/js/bom.js b/InvenTree/templates/js/bom.js index c97d8b30e5..e35a51d8bd 100644 --- a/InvenTree/templates/js/bom.js +++ b/InvenTree/templates/js/bom.js @@ -249,19 +249,6 @@ function loadBomTable(table, options) { title: '{% trans "Purchase Price Range" %}', searchable: false, sortable: true, - formatter: function(value, row, index, field) { - var purchase_price_range = 0; - - if (row.purchase_price_min > 0) { - if (row.purchase_price_min >= row.purchase_price_max) { - purchase_price_range = row.purchase_price_min; - } else { - purchase_price_range = row.purchase_price_min + " - " + row.purchase_price_max; - } - } - - return purchase_price_range; - }, }); cols.push( From 274eb51e48d7e8e4cea773b3223f33582791c869 Mon Sep 17 00:00:00 2001 From: eeintech Date: Fri, 14 May 2021 16:29:55 -0400 Subject: [PATCH 05/37] Added read_only args --- InvenTree/part/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 868997915b..b6e24fd199 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -372,9 +372,9 @@ class BomItemSerializer(InvenTreeModelSerializer): purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True) - purchase_price_avg = serializers.SerializerMethodField() + purchase_price_avg = serializers.SerializerMethodField(read_only=True) - purchase_price_range = serializers.SerializerMethodField() + purchase_price_range = serializers.SerializerMethodField(read_only=True) def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. From e9f41a83576f26b08e3f13b4c7e380184752b224 Mon Sep 17 00:00:00 2001 From: eeintech Date: Fri, 14 May 2021 16:38:30 -0400 Subject: [PATCH 06/37] Currency finding fix --- InvenTree/part/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 065eca30c6..5bdd572145 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -928,7 +928,7 @@ class BomList(generics.ListCreateAPIView): # 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: + 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 From 5ce262172d77d133ee591751adab63eb86fd663f Mon Sep 17 00:00:00 2001 From: eeintech Date: Fri, 14 May 2021 16:59:59 -0400 Subject: [PATCH 07/37] Fixed bom_item unit test --- InvenTree/part/serializers.py | 39 ++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index b6e24fd199..04e0b7a119 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -372,9 +372,9 @@ class BomItemSerializer(InvenTreeModelSerializer): purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True) - purchase_price_avg = serializers.SerializerMethodField(read_only=True) + purchase_price_avg = serializers.SerializerMethodField() - purchase_price_range = serializers.SerializerMethodField(read_only=True) + purchase_price_range = serializers.SerializerMethodField() def __init__(self, *args, **kwargs): # part_detail and sub_part_detail serializers are only included if requested. @@ -406,19 +406,29 @@ class BomItemSerializer(InvenTreeModelSerializer): def get_purchase_price_range(self, obj): """ Return purchase price range """ - if obj.purchase_price_min and not obj.purchase_price_max: + 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(obj.purchase_price_max) - elif not obj.purchase_price_min and obj.purchase_price_max: + purchase_price_range = str(purchase_price_max) + elif not purchase_price_min and purchase_price_max: # Get price range - purchase_price_range = str(obj.purchase_price_max) - elif obj.purchase_price_min and obj.purchase_price_max: + purchase_price_range = str(purchase_price_max) + elif purchase_price_min and purchase_price_max: # Get price range - if obj.purchase_price_min >= obj.purchase_price_max: + if purchase_price_min >= purchase_price_max: # If min > max: use min only - purchase_price_range = str(obj.purchase_price_min) + purchase_price_range = str(purchase_price_min) else: - purchase_price_range = str(obj.purchase_price_min) + " - " + str(obj.purchase_price_max) + purchase_price_range = str(purchase_price_min) + " - " + str(purchase_price_max) else: purchase_price_range = '-' @@ -427,9 +437,14 @@ class BomItemSerializer(InvenTreeModelSerializer): def get_purchase_price_avg(self, obj): """ Return purchase price average """ - if obj.purchase_price_avg: + 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(obj.purchase_price_avg) + purchase_price_avg = str(purchase_price_avg) else: purchase_price_avg = '-' From 34ded08ee717a961d5d7eaf70590f415416ca4a6 Mon Sep 17 00:00:00 2001 From: eeintech Date: Wed, 19 May 2021 16:17:03 -0400 Subject: [PATCH 08/37] Added InvenTreeFixerExchangeBackend class --- InvenTree/InvenTree/exchange.py | 44 +++++++++++++++++++++++++++++++++ InvenTree/InvenTree/settings.py | 2 +- InvenTree/InvenTree/tasks.py | 26 ++++++++----------- InvenTree/config_template.yaml | 7 ++++++ 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 06de4861ec..59d2883e2a 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,4 +1,10 @@ +from django.conf import settings as inventree_settings + +from djmoney import settings as djmoney_settings from djmoney.contrib.exchange.backends.base import BaseExchangeBackend +from djmoney.contrib.exchange.backends import FixerBackend + +from common.models import InvenTreeSetting class InvenTreeManualExchangeBackend(BaseExchangeBackend): @@ -19,3 +25,41 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): """ return {} + + +class InvenTreeFixerExchangeBackend(FixerBackend): + """ + Backend for updating currency exchange rates using Fixer.IO API + """ + + def get_api_key(self): + """ Get API key from global settings """ + + fixer_api_key = InvenTreeSetting.get_setting('INVENTREE_FIXER_API_KEY', '').strip() + + if not fixer_api_key: + # API key not provided + return None + + return fixer_api_key + + def __init__(self): + """ Override FixerBackend init to get access_key from global settings """ + + fixer_api_key = self.get_api_key() + + super().__init__(url=djmoney_settings.FIXER_URL, access_key=fixer_api_key) + + def update_rates(self): + """ Override update_rates method using currencies found in the settings """ + + currencies = ','.join(inventree_settings.CURRENCIES) + + base = inventree_settings.BASE_CURRENCY + + super().update_rates(base_currency=base, symbols=currencies) + + def get_rates(self, **kwargs): + """ Returns a mapping : """ + + return {} diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 5cf0b0c544..e99c3a3eea 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -515,7 +515,7 @@ CURRENCIES = CONFIG.get( BASE_CURRENCY = CONFIG.get('base_currency', 'USD') -EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend' +EXCHANGE_BACKEND = 'InvenTree.exchange.' + CONFIG.get('exchange_backend', '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..0076bb9544 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 backend rates """ try: - import common.models + from .exchange import InvenTreeManualExchangeBackend, InvenTreeFixerExchangeBackend from django.conf import settings - from djmoney.contrib.exchange.backends import FixerBackend except AppRegistryNotReady: # Apps not yet loaded! return + + # Get backend + if 'InvenTreeManualExchangeBackend' in settings.EXCHANGE_BACKEND: + backend = InvenTreeFixerExchangeBackend() + else: + backend = InvenTreeManualExchangeBackend() - fixer_api_key = common.models.InvenTreeSetting.get_setting('INVENTREE_FIXER_API_KEY', '').strip() - - if not fixer_api_key: - # API key not provided - return - - backend = FixerBackend(access_key=fixer_api_key) - - currencies = ','.join(settings.CURRENCIES) - - base = settings.BASE_CURRENCY - - backend.update_rates(base_currency=base, symbols=currencies) + # Update rates + backend.update_rates() def send_email(subject, body, recipients, from_email=None): diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 87dfb6b545..63525b0d79 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -62,6 +62,13 @@ currencies: - JPY - NZD - USD +# Define base currency (can also be defined in the global settings) +# base_currency: USD +# Define exchange backend +# Choices are: +# - InvenTreeManualExchangeBackend +# - InvenTreeFixerExchangeBackend +exchange_backend: InvenTreeFixerExchangeBackend # Email backend configuration # Ref: https://docs.djangoproject.com/en/dev/topics/email/ From 6d5b2d3227f1bf0a92971e473ab226374e8359a3 Mon Sep 17 00:00:00 2001 From: eeintech Date: Wed, 19 May 2021 17:06:41 -0400 Subject: [PATCH 09/37] Added 'Currencies' settings view in global settings --- InvenTree/InvenTree/tasks.py | 4 +- InvenTree/InvenTree/urls.py | 3 ++ InvenTree/InvenTree/views.py | 15 +++++++ .../InvenTree/settings/currencies.html | 43 +++++++++++++++++++ .../templates/InvenTree/settings/global.html | 2 - .../templates/InvenTree/settings/tabs.html | 3 ++ 6 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 InvenTree/templates/InvenTree/settings/currencies.html diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 0076bb9544..fb6a45f368 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -175,9 +175,9 @@ def update_exchange_rates(): # Get backend if 'InvenTreeManualExchangeBackend' in settings.EXCHANGE_BACKEND: - backend = InvenTreeFixerExchangeBackend() - else: backend = InvenTreeManualExchangeBackend() + else: + backend = InvenTreeFixerExchangeBackend() # Update rates backend.update_rates() diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index ab2ced7d5e..d3132ca2a8 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 ExchangeRatesView from common.views import SettingEdit @@ -90,6 +91,8 @@ 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/?', SettingsView.as_view(template_name='InvenTree/settings/currencies.html'), name='settings-currencies'), + url(r'^echange-rates/?', ExchangeRatesView.as_view(), name='refresh-exchange-rates'), 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..bd03a18dda 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -27,6 +27,7 @@ from users.models import check_user_role, RuleSet from .forms import DeleteForm, EditUserForm, SetPasswordForm from .forms import ColorThemeSelectForm, SettingCategorySelectForm from .helpers import str2bool +from .tasks import update_exchange_rates from rest_framework import views @@ -908,3 +909,17 @@ class DatabaseStatsView(AjaxView): """ return ctx + + +class ExchangeRatesView(SettingsView): + + success_url = reverse_lazy('settings-currencies') + + def post(self, request, *args, **kwargs): + + # Process exchange rates + update_exchange_rates() + + # TODO: Update context + + return HttpResponseRedirect(self.success_url) diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html new file mode 100644 index 0000000000..9ec5b518fb --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -0,0 +1,43 @@ +{% 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="INVENTREE_FIXER_API_KEY" icon="fa-key" %} + +
+ +
+
+

{% trans "Exchange Rates" %}

+
+
+ + +
+ +
+ {% csrf_token %} + +
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} +{% comment %} TODO: Update exchange-rates table! {% endcomment %} +{% comment %} Or do it using context instead of JS? {% endcomment %} +{% 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..fd91dafc67 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" %} 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 %} From bed6a7e49c11f2cbb8ad4eb12ef1e6a16f16e616 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 20 May 2021 09:49:56 -0400 Subject: [PATCH 10/37] Added exchange rates form --- InvenTree/InvenTree/exchange.py | 61 +++++++++++++------ InvenTree/InvenTree/forms.py | 36 ++++++++++- InvenTree/InvenTree/tasks.py | 10 +-- InvenTree/InvenTree/urls.py | 5 +- InvenTree/InvenTree/views.py | 37 ++++++++++- .../InvenTree/settings/currencies.html | 19 +++--- 6 files changed, 125 insertions(+), 43 deletions(-) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 59d2883e2a..03891828a0 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,12 +1,21 @@ +from django.core.exceptions import ImproperlyConfigured from django.conf import settings as inventree_settings from djmoney import settings as djmoney_settings from djmoney.contrib.exchange.backends.base import BaseExchangeBackend -from djmoney.contrib.exchange.backends import FixerBackend from common.models import InvenTreeSetting +def get_exchange_rate_backend(): + """ Return the exchange rate backend set by user """ + + if 'InvenTreeManualExchangeBackend' in inventree_settings.EXCHANGE_BACKEND: + return InvenTreeManualExchangeBackend() + else: + return InvenTreeFixerExchangeBackend() + + class InvenTreeManualExchangeBackend(BaseExchangeBackend): """ Backend for manually updating currency exchange rates @@ -16,22 +25,39 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): Specifically: https://github.com/django-money/django-money/tree/master/djmoney/contrib/exchange/backends """ - name = "inventree" + name = 'inventree' url = None + default_currency = None + currencies = [] + + def update_default_currency(self): + + self.default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', inventree_settings.BASE_CURRENCY) + + def __init__(self, url=None): + + self.url = url + + self.update_default_currency() + + self.currencies = inventree_settings.CURRENCIES + + super().__init__() def get_rates(self, **kwargs): - """ - Do not get any rates... - """ + """ Returns a mapping : """ return {} -class InvenTreeFixerExchangeBackend(FixerBackend): +class InvenTreeFixerExchangeBackend(InvenTreeManualExchangeBackend): """ Backend for updating currency exchange rates using Fixer.IO API """ + name = 'fixer.io' + access_key = None + def get_api_key(self): """ Get API key from global settings """ @@ -48,18 +74,17 @@ class InvenTreeFixerExchangeBackend(FixerBackend): fixer_api_key = self.get_api_key() - super().__init__(url=djmoney_settings.FIXER_URL, access_key=fixer_api_key) + if fixer_api_key is None: + raise ImproperlyConfigured("fixer.io API key is needed to use InvenTreeFixerExchangeBackend") + + self.access_key = fixer_api_key + + super().__init__(url=djmoney_settings.FIXER_URL) def update_rates(self): - """ Override update_rates method using currencies found in the settings """ + """ Override update_rates method using currencies found in the settings + """ + + symbols = ','.join(self.currencies) - currencies = ','.join(inventree_settings.CURRENCIES) - - base = inventree_settings.BASE_CURRENCY - - super().update_rates(base_currency=base, symbols=currencies) - - def get_rates(self, **kwargs): - """ Returns a mapping : """ - - return {} + super().update_rates(base_currency=self.base_currency, symbols=symbols) diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 52d1c8758f..a744671afb 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -7,12 +7,15 @@ 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): @@ -236,3 +239,34 @@ 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.default_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', + 'type': 'number', + 'min': '0', + 'step': 'any', + 'value': '', + }) + ) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index fb6a45f368..0468365dae 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -167,17 +167,13 @@ def update_exchange_rates(): """ try: - from .exchange import InvenTreeManualExchangeBackend, InvenTreeFixerExchangeBackend - from django.conf import settings + from .exchange import get_exchange_rate_backend except AppRegistryNotReady: # Apps not yet loaded! return - # Get backend - if 'InvenTreeManualExchangeBackend' in settings.EXCHANGE_BACKEND: - backend = InvenTreeManualExchangeBackend() - else: - backend = InvenTreeFixerExchangeBackend() + # Get exchange rate backend + backend = get_exchange_rate_backend() # Update rates backend.update_rates() diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index d3132ca2a8..d297dc18ad 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -41,7 +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 ExchangeRatesView +from .views import CurrencySettingsView from common.views import SettingEdit @@ -91,8 +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/?', SettingsView.as_view(template_name='InvenTree/settings/currencies.html'), name='settings-currencies'), - url(r'^echange-rates/?', ExchangeRatesView.as_view(), name='refresh-exchange-rates'), + 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/views.py b/InvenTree/InvenTree/views.py index bd03a18dda..50f4a095e8 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -26,8 +26,9 @@ from users.models import check_user_role, RuleSet from .forms import DeleteForm, EditUserForm, SetPasswordForm from .forms import ColorThemeSelectForm, SettingCategorySelectForm +from .forms import SettingExchangeRatesForm from .helpers import str2bool -from .tasks import update_exchange_rates +from .exchange import get_exchange_rate_backend from rest_framework import views @@ -911,14 +912,44 @@ class DatabaseStatsView(AjaxView): return ctx -class ExchangeRatesView(SettingsView): +class CurrencySettingsView(FormView): + form_class = SettingExchangeRatesForm + template_name = 'InvenTree/settings/currencies.html' success_url = reverse_lazy('settings-currencies') + def get_context_data(self, **kwargs): + + context = super().get_context_data(**kwargs) + + # Get exchange rate backend + exchange_rate_backend = get_exchange_rate_backend() + + context['exchange_backend'] = exchange_rate_backend.name + + return context + + def get_form(self): + + form = super().get_form() + + # Get exchange rate backend + exchange_rate_backend = get_exchange_rate_backend() + + if exchange_rate_backend.name == 'fixer.io': + # Disable all the fields + for field in form.fields: + form.fields[field].disabled = True + + return form + def post(self, request, *args, **kwargs): + # Get exchange rate backend + exchange_rate_backend = get_exchange_rate_backend() + # Process exchange rates - update_exchange_rates() + exchange_rate_backend.update_rates() # TODO: Update context diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index 9ec5b518fb..52d6558bad 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -26,18 +26,15 @@ - -
    - -
    + {% csrf_token %} - + {% load crispy_forms_tags %} + {% crispy form %} + {% if exchange_backend == 'fixer.io' %} + + {% else %} + + {% endif %}
    {% endblock %} - -{% block js_ready %} -{{ block.super }} -{% comment %} TODO: Update exchange-rates table! {% endcomment %} -{% comment %} Or do it using context instead of JS? {% endcomment %} -{% endblock %} \ No newline at end of file From 747b0554e1f92323a3da20e82a3415cafc74a639 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 20 May 2021 13:45:26 -0400 Subject: [PATCH 11/37] Ready for review --- InvenTree/InvenTree/exchange.py | 75 +++++++++++++++---- InvenTree/InvenTree/forms.py | 6 +- InvenTree/InvenTree/helpers.py | 18 ++++- InvenTree/InvenTree/views.py | 61 ++++++++++++--- InvenTree/common/forms.py | 22 +----- .../InvenTree/settings/currencies.html | 14 +++- 6 files changed, 148 insertions(+), 48 deletions(-) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 03891828a0..ce1db06711 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -3,6 +3,7 @@ from django.conf import settings as inventree_settings from djmoney import settings as djmoney_settings from djmoney.contrib.exchange.backends.base import BaseExchangeBackend +from djmoney.contrib.exchange.models import Rate from common.models import InvenTreeSetting @@ -27,19 +28,24 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): name = 'inventree' url = None - default_currency = None + base_currency = None currencies = [] def update_default_currency(self): + """ Update to base currency """ - self.default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', inventree_settings.BASE_CURRENCY) + self.base_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', inventree_settings.BASE_CURRENCY) 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__() @@ -47,7 +53,22 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): def get_rates(self, **kwargs): """ Returns a mapping : """ - return {} + 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 InvenTreeFixerExchangeBackend(InvenTreeManualExchangeBackend): @@ -55,7 +76,7 @@ class InvenTreeFixerExchangeBackend(InvenTreeManualExchangeBackend): Backend for updating currency exchange rates using Fixer.IO API """ - name = 'fixer.io' + name = 'fixer' access_key = None def get_api_key(self): @@ -67,24 +88,48 @@ class InvenTreeFixerExchangeBackend(InvenTreeManualExchangeBackend): # API key not provided return None - return fixer_api_key - - def __init__(self): - """ Override FixerBackend init to get access_key from global settings """ - - fixer_api_key = self.get_api_key() - - if fixer_api_key is None: - raise ImproperlyConfigured("fixer.io API key is needed to use InvenTreeFixerExchangeBackend") - self.access_key = fixer_api_key + def __init__(self): + """ Override init to get access_key from global settings """ + + self.get_api_key() + + if self.access_key is None: + raise ImproperlyConfigured("fixer.io API key is needed to use InvenTreeFixerExchangeBackend") + super().__init__(url=djmoney_settings.FIXER_URL) - def update_rates(self): + def get_params(self): + """ Returns parameters (access key) """ + + return {"access_key": self.access_key} + + 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 a744671afb..488e982ddc 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -15,6 +15,7 @@ from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppende from common.models import ColorTheme from part.models import PartCategory + from .exchange import InvenTreeManualExchangeBackend @@ -254,7 +255,7 @@ class SettingExchangeRatesForm(forms.Form): exchange_rate_backend.update_default_currency() for currency in exchange_rate_backend.currencies: - if currency != exchange_rate_backend.default_currency: + if currency != exchange_rate_backend.base_currency: # Set field name field_name = currency # Set field input box @@ -264,9 +265,10 @@ class SettingExchangeRatesForm(forms.Form): widget=forms.NumberInput(attrs={ 'name': field_name, 'class': 'numberinput', + 'style': 'width: 200px;', 'type': 'number', 'min': '0', 'step': 'any', - 'value': '', + '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/views.py b/InvenTree/InvenTree/views.py index 50f4a095e8..48e42dd890 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -23,6 +23,7 @@ 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 @@ -918,12 +919,30 @@ class CurrencySettingsView(FormView): 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 = get_exchange_rate_backend() + exchange_rate_backend = self.get_exchange_rate_backend() + + context['default_currency'] = exchange_rate_backend.base_currency context['exchange_backend'] = exchange_rate_backend.name @@ -934,23 +953,45 @@ class CurrencySettingsView(FormView): form = super().get_form() # Get exchange rate backend - exchange_rate_backend = get_exchange_rate_backend() + exchange_rate_backend = self.get_exchange_rate_backend() - if exchange_rate_backend.name == 'fixer.io': - # Disable all the fields - for field in form.fields: + # Get stored exchange rates + stored_rates = exchange_rate_backend.get_stored_rates() + + for field in form.fields: + if 'fixer' in exchange_rate_backend.name: + # 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 = get_exchange_rate_backend() + exchange_rate_backend = self.get_exchange_rate_backend() - # Process exchange rates - exchange_rate_backend.update_rates() + if 'fixer' in exchange_rate_backend.name: + # 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 = {} - # TODO: Update context + if form.is_valid(): + for field, value in form.cleaned_data.items(): + manual_rates[field] = clean_decimal(value) - return HttpResponseRedirect(self.success_url) + 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/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index 52d6558bad..10b5709238 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -22,11 +22,12 @@
    -

    {% trans "Exchange Rates" %}

    +

    {% trans "Exchange Rates - Convert to " %}{{ default_currency }}

    +
    {% csrf_token %} {% load crispy_forms_tags %} {% crispy form %} @@ -35,6 +36,17 @@ {% else %} {% endif %} +
    {% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% if api_rates_success is False %} + var alert_msg = {% blocktrans %}"Failed to refresh exchange rates. Verify your API key and/or subscription plan" {% endblocktrans %}; + showAlertOrCache("alert-danger", alert_msg, null, 5000); +{% endif %} + +{% endblock %} \ No newline at end of file From 27799b43b236525b818fa32d2d4c66b57e0fa96b Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 20 May 2021 13:51:54 -0400 Subject: [PATCH 12/37] Template fix/improvement --- InvenTree/templates/InvenTree/settings/currencies.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index 10b5709238..fa9d234988 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -16,7 +16,9 @@ {% include "InvenTree/settings/header.html" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} + {% if 'fixer' in exchange_backend %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_FIXER_API_KEY" icon="fa-key" %} + {% endif %} @@ -31,7 +33,7 @@ {% csrf_token %} {% load crispy_forms_tags %} {% crispy form %} - {% if exchange_backend == 'fixer.io' %} + {% if 'fixer' in exchange_backend %} {% else %} From bbd95f2c7059255b6217d01ea0c338b96deb8eae Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 20 May 2021 14:52:56 -0400 Subject: [PATCH 13/37] Fixed exchange rate backend test --- InvenTree/InvenTree/tasks.py | 2 +- InvenTree/company/tests.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 0468365dae..b3649bcdcf 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -176,7 +176,7 @@ def update_exchange_rates(): backend = get_exchange_rate_backend() # Update rates - backend.update_rates() + backend.update_rates(base_currency=backend.base_currency) def send_email(subject, body, recipients, from_email=None): diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index 5dd3bf81ab..e07516e180 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -11,7 +11,7 @@ from .models import Company, Contact, ManufacturerPart, SupplierPart from .models import rename_company_image from part.models import Part -from InvenTree.exchange import InvenTreeManualExchangeBackend +from InvenTree.exchange import get_exchange_rate_backend from djmoney.contrib.exchange.models import Rate @@ -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 = get_exchange_rate_backend() + 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): From be3f37f28f286fcf05ef6b24af6526a6835331ed Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 20 May 2021 15:22:18 -0400 Subject: [PATCH 14/37] Updated default currency backend --- InvenTree/config_template.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 63525b0d79..7d301934f0 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -68,7 +68,7 @@ currencies: # Choices are: # - InvenTreeManualExchangeBackend # - InvenTreeFixerExchangeBackend -exchange_backend: InvenTreeFixerExchangeBackend +exchange_backend: InvenTreeManualExchangeBackend # Email backend configuration # Ref: https://docs.djangoproject.com/en/dev/topics/email/ From 53ce848145c3137581ce0b8ea7b6315e82165b74 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 22 May 2021 14:48:56 +0200 Subject: [PATCH 15/37] better translation --- InvenTree/templates/InvenTree/settings/currencies.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index fa9d234988..d9b046b9f5 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -24,7 +24,7 @@
    -

    {% trans "Exchange Rates - Convert to " %}{{ default_currency }}

    +

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

    From 4503f23ae4e7c8292c59c93ba66d7a6a021d71a4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 22 May 2021 14:55:05 +0200 Subject: [PATCH 16/37] beeing safe with wrong / unknown setttings --- InvenTree/InvenTree/exchange.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index ce1db06711..8de0c9902f 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -13,8 +13,10 @@ def get_exchange_rate_backend(): if 'InvenTreeManualExchangeBackend' in inventree_settings.EXCHANGE_BACKEND: return InvenTreeManualExchangeBackend() - else: + elif 'InvenTreeFixerExchangeBackend' in inventree_settings.EXCHANGE_BACKEND: return InvenTreeFixerExchangeBackend() + else: + raise ImproperlyConfigured('Exchange Backend wrongly configured') class InvenTreeManualExchangeBackend(BaseExchangeBackend): From b1b974a1f611141dd5c8a751bcfc0b79264ef756 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 22 May 2021 14:55:41 +0200 Subject: [PATCH 17/37] safer check if fixer-backend is used --- InvenTree/InvenTree/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 48e42dd890..778316ddd5 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -959,7 +959,7 @@ class CurrencySettingsView(FormView): stored_rates = exchange_rate_backend.get_stored_rates() for field in form.fields: - if 'fixer' in exchange_rate_backend.name: + if exchange_rate_backend.name.startswith('fixer-'): # Disable all the fields form.fields[field].disabled = True form.fields[field].initial = clean_decimal(stored_rates.get(field, 0)) @@ -973,7 +973,7 @@ class CurrencySettingsView(FormView): # Get exchange rate backend exchange_rate_backend = self.get_exchange_rate_backend() - if 'fixer' in exchange_rate_backend.name: + if exchange_rate_backend.name.startswith('fixer-'): # Refresh rate from Fixer.IO API exchange_rate_backend.update_rates(base_currency=exchange_rate_backend.base_currency) # Check if rates have been updated From 65aa46816815b0a0ffe6153513b6018f909432bb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 23 May 2021 21:14:43 +1000 Subject: [PATCH 18/37] Add backend for exchangerate.host --- InvenTree/InvenTree/exchange.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 06de4861ec..a4ac06e24a 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,4 +1,4 @@ -from djmoney.contrib.exchange.backends.base import BaseExchangeBackend +from djmoney.contrib.exchange.backends.base import BaseExchangeBackend, SimpleExchangeBackend class InvenTreeManualExchangeBackend(BaseExchangeBackend): @@ -19,3 +19,18 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): """ return {} + + +class ExchangeRateHostBackend(SimpleExchangeBackend): + """ + Backend for https://exchangerate.host/ + """ + + name = "exchangerate.host" + + def __init__(self): + self.url = "https://api.exchangerate.host/latest" + + def get_params(self): + # No API key is required + return {} From ff80c722fb2023d955e8fc15621db62953d92bc8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 25 May 2021 09:32:21 +1000 Subject: [PATCH 19/37] Docker: Add parent directories if they do not exist --- docker/Dockerfile | 3 ++- docker/start_dev_server.sh | 4 ++-- docker/start_prod_server.sh | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) 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 From 619cba6007c92c0ac75d6c2c755e8e7fdea58df5 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 25 May 2021 10:37:53 +1000 Subject: [PATCH 20/37] Currency: Use ExchangeRateHost backend by default, rather than fixer.io - No longer support fixer.io as it requires an API key and does not support "base" currency --- InvenTree/InvenTree/tasks.py | 10 ++-------- InvenTree/common/models.py | 6 ------ InvenTree/templates/InvenTree/settings/global.html | 1 - 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index b81b4e6de4..e9e37e63a3 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -169,18 +169,12 @@ def update_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() - - if not fixer_api_key: - # API key not provided - return - - backend = FixerBackend(access_key=fixer_api_key) + backend = ExchangeRateHostBackend() currencies = ','.join(settings.CURRENCIES) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index e499e9b801..99712b2a93 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -87,12 +87,6 @@ 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': '', - }, - 'INVENTREE_DOWNLOAD_FROM_URL': { 'name': _('Download from URL'), 'description': _('Allow download of remote images and files from external URL'), diff --git a/InvenTree/templates/InvenTree/settings/global.html b/InvenTree/templates/InvenTree/settings/global.html index 5c5dccfb2a..a0347490d0 100644 --- a/InvenTree/templates/InvenTree/settings/global.html +++ b/InvenTree/templates/InvenTree/settings/global.html @@ -20,7 +20,6 @@ {% 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" %} From 0a26a069092e3a0d0f6a0ebce929c38ac174dba6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 25 May 2021 10:46:30 +1000 Subject: [PATCH 21/37] Use INVENTREE_DEFAULT_CURRENCY as specified base currency --- InvenTree/InvenTree/settings.py | 4 +--- InvenTree/InvenTree/tasks.py | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 5cf0b0c544..f0a3312b39 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -513,9 +513,7 @@ CURRENCIES = CONFIG.get( ], ) -BASE_CURRENCY = CONFIG.get('base_currency', 'USD') - -EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeManualExchangeBackend' +EXCHANGE_BACKEND = 'InvenTree.exchange.ExchangeRateHostBackend' # 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 e9e37e63a3..ad33232fe0 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -175,10 +175,13 @@ def update_exchange_rates(): return backend = ExchangeRateHostBackend() + print(f"Updating exchange rates from {backend.url}") currencies = ','.join(settings.CURRENCIES) - base = settings.BASE_CURRENCY + base = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') + + print(f"Using base currency '{base}'") backend.update_rates(base_currency=base, symbols=currencies) From 93bfe4c5f11d05e873629d4427ae13fae3d0b205 Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 25 May 2021 11:19:07 -0400 Subject: [PATCH 22/37] Added 'Custom Exchange Rate' boolea setting Removed Fixer.io exchange rate backend --- InvenTree/InvenTree/exchange.py | 60 +++++-------------- InvenTree/InvenTree/settings.py | 2 - InvenTree/InvenTree/tasks.py | 7 +-- InvenTree/InvenTree/views.py | 6 +- InvenTree/common/models.py | 7 +++ InvenTree/config_template.yaml | 7 --- .../InvenTree/settings/currencies.html | 6 +- 7 files changed, 29 insertions(+), 66 deletions(-) diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 2a2d287e1c..0a75436b1e 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,7 +1,5 @@ -from django.core.exceptions import ImproperlyConfigured from django.conf import settings as inventree_settings -from djmoney import settings as djmoney_settings from djmoney.contrib.exchange.backends.base import BaseExchangeBackend from djmoney.contrib.exchange.models import Rate @@ -11,12 +9,12 @@ from common.models import InvenTreeSetting def get_exchange_rate_backend(): """ Return the exchange rate backend set by user """ - if 'InvenTreeManualExchangeBackend' in inventree_settings.EXCHANGE_BACKEND: + custom = InvenTreeSetting.get_setting('CUSTOM_EXCHANGE_RATES', False) + + if custom: return InvenTreeManualExchangeBackend() - elif 'InvenTreeFixerExchangeBackend' in inventree_settings.EXCHANGE_BACKEND: - return InvenTreeFixerExchangeBackend() else: - raise ImproperlyConfigured('Exchange Backend wrongly configured') + return ExchangeRateHostBackend() class InvenTreeManualExchangeBackend(BaseExchangeBackend): @@ -30,13 +28,14 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): 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', inventree_settings.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 """ @@ -73,39 +72,23 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend): return stored_rates -class InvenTreeFixerExchangeBackend(InvenTreeManualExchangeBackend): +class ExchangeRateHostBackend(InvenTreeManualExchangeBackend): """ - Backend for updating currency exchange rates using Fixer.IO API + Backend for https://exchangerate.host/ """ - name = 'fixer' - access_key = None - - def get_api_key(self): - """ Get API key from global settings """ - - fixer_api_key = InvenTreeSetting.get_setting('INVENTREE_FIXER_API_KEY', '').strip() - - if not fixer_api_key: - # API key not provided - return None - - self.access_key = fixer_api_key + name = "exchangerate.host" def __init__(self): - """ Override init to get access_key from global settings """ + self.url = "https://api.exchangerate.host/latest" - self.get_api_key() + self.custom_rates = False - if self.access_key is None: - raise ImproperlyConfigured("fixer.io API key is needed to use InvenTreeFixerExchangeBackend") - - super().__init__(url=djmoney_settings.FIXER_URL) + super().__init__(url=self.url) def get_params(self): - """ Returns parameters (access key) """ - - return {"access_key": self.access_key} + # No API key is required + return {} def update_rates(self, base_currency=None): """ Override update_rates method using currencies found in the settings @@ -135,18 +118,3 @@ class InvenTreeFixerExchangeBackend(InvenTreeManualExchangeBackend): pass return {} - - -class ExchangeRateHostBackend(SimpleExchangeBackend): - """ - Backend for https://exchangerate.host/ - """ - - name = "exchangerate.host" - - def __init__(self): - self.url = "https://api.exchangerate.host/latest" - - def get_params(self): - # No API key is required - return {} diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index f0a3312b39..afa43396f5 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -513,8 +513,6 @@ CURRENCIES = CONFIG.get( ], ) -EXCHANGE_BACKEND = 'InvenTree.exchange.ExchangeRateHostBackend' - # 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 ad33232fe0..365a94fd07 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -163,12 +163,11 @@ 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 InvenTree.exchange import ExchangeRateHostBackend except AppRegistryNotReady: # Apps not yet loaded! @@ -177,13 +176,11 @@ def update_exchange_rates(): backend = ExchangeRateHostBackend() print(f"Updating exchange rates from {backend.url}") - currencies = ','.join(settings.CURRENCIES) - base = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY') print(f"Using base currency '{base}'") - 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/views.py b/InvenTree/InvenTree/views.py index 778316ddd5..a18845bf02 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -944,6 +944,8 @@ class CurrencySettingsView(FormView): 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 @@ -959,7 +961,7 @@ class CurrencySettingsView(FormView): stored_rates = exchange_rate_backend.get_stored_rates() for field in form.fields: - if exchange_rate_backend.name.startswith('fixer-'): + 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)) @@ -973,7 +975,7 @@ class CurrencySettingsView(FormView): # Get exchange rate backend exchange_rate_backend = self.get_exchange_rate_backend() - if exchange_rate_backend.name.startswith('fixer-'): + 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 diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 99712b2a93..74c6c82b41 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -87,6 +87,13 @@ class InvenTreeSetting(models.Model): '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'), diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 7d301934f0..87dfb6b545 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -62,13 +62,6 @@ currencies: - JPY - NZD - USD -# Define base currency (can also be defined in the global settings) -# base_currency: USD -# Define exchange backend -# Choices are: -# - InvenTreeManualExchangeBackend -# - InvenTreeFixerExchangeBackend -exchange_backend: InvenTreeManualExchangeBackend # Email backend configuration # Ref: https://docs.djangoproject.com/en/dev/topics/email/ diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index d9b046b9f5..b6cf9fea81 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -16,9 +16,7 @@ {% include "InvenTree/settings/header.html" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} - {% if 'fixer' in exchange_backend %} - {% include "InvenTree/settings/setting.html" with key="INVENTREE_FIXER_API_KEY" icon="fa-key" %} - {% endif %} + {% include "InvenTree/settings/setting.html" with key="CUSTOM_EXCHANGE_RATES" icon="fa-edit" %} @@ -33,7 +31,7 @@ {% csrf_token %} {% load crispy_forms_tags %} {% crispy form %} - {% if 'fixer' in exchange_backend %} + {% if custom_rates is False %} {% else %} From b04ad48178f72fabed21bb6ac7255f716095f200 Mon Sep 17 00:00:00 2001 From: eeintech Date: Tue, 25 May 2021 11:39:04 -0400 Subject: [PATCH 23/37] Fixed test using manual exchange backend, template update --- InvenTree/company/tests.py | 4 ++-- InvenTree/templates/InvenTree/settings/currencies.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index e07516e180..2c6e722440 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -11,7 +11,7 @@ from .models import Company, Contact, ManufacturerPart, SupplierPart from .models import rename_company_image from part.models import Part -from InvenTree.exchange import get_exchange_rate_backend +from InvenTree.exchange import InvenTreeManualExchangeBackend from djmoney.contrib.exchange.models import Rate @@ -42,7 +42,7 @@ class CompanySimpleTest(TestCase): self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312') # Exchange rate backend - backend = get_exchange_rate_backend() + backend = InvenTreeManualExchangeBackend() backend.update_rates(base_currency=backend.base_currency) Rate.objects.create( diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html index b6cf9fea81..dd47bc6cdd 100644 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ b/InvenTree/templates/InvenTree/settings/currencies.html @@ -45,7 +45,7 @@ {{ block.super }} {% if api_rates_success is False %} - var alert_msg = {% blocktrans %}"Failed to refresh exchange rates. Verify your API key and/or subscription plan" {% endblocktrans %}; + var alert_msg = {% blocktrans %}"Failed to refresh exchange rates" {% endblocktrans %}; showAlertOrCache("alert-danger", alert_msg, null, 5000); {% endif %} From c2fe5e08b4bb52277050309d98596f275b5ef040 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 27 May 2021 12:35:55 +1000 Subject: [PATCH 24/37] 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 29/37] 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 30/37] 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 31/37] 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') From 52fc698b51c8b87a02cecb5c1cf613786cf31e03 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 28 May 2021 12:07:53 +1000 Subject: [PATCH 32/37] Remove debug message --- InvenTree/InvenTree/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 4ac90bd722..108908c571 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -775,6 +775,9 @@ class SettingsView(TemplateView): class CurrencyRefreshView(RedirectView): + """ + POST endpoint to refresh / update exchange rates + """ url = reverse_lazy("settings-currencies") @@ -783,8 +786,6 @@ class CurrencyRefreshView(RedirectView): 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() From 4ddeab3330fa2af12254a56d02aa17c79cac929a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 28 May 2021 12:44:39 +1000 Subject: [PATCH 33/37] Update exchange rates when launching the server - Ensures that the exchange rates don't get messed up if the base currency is changed! --- InvenTree/InvenTree/apps.py | 48 ++++++++++++++++++++++++++++++++++++ InvenTree/InvenTree/tasks.py | 4 +++ 2 files changed, 52 insertions(+) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index aa60058dcf..465dc2087b 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -4,6 +4,7 @@ import logging from django.apps import AppConfig from django.core.exceptions import AppRegistryNotReady +from django.conf import settings from InvenTree.ready import canAppAccessDatabase import InvenTree.tasks @@ -19,6 +20,7 @@ class InvenTreeConfig(AppConfig): if canAppAccessDatabase(): self.start_background_tasks() + self.update_exchange_rates() def start_background_tasks(self): @@ -49,3 +51,49 @@ 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 + + if update: + update_exchange_rates() diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 9a71d5d84c..92c58c24a8 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -168,6 +168,7 @@ def update_exchange_rates(): try: from InvenTree.exchange import InvenTreeExchange + from djmoney.contrib.exchange.models import Rate from django.conf import settings except AppRegistryNotReady: # Apps not yet loaded! @@ -182,6 +183,9 @@ def update_exchange_rates(): 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): """ From 09782353703f6866d26573b5eade9821c7f49ae1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 28 May 2021 12:49:50 +1000 Subject: [PATCH 34/37] Fix? --- InvenTree/InvenTree/tasks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 92c58c24a8..994a344cc9 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -173,6 +173,9 @@ def update_exchange_rates(): except AppRegistryNotReady: # Apps not yet loaded! return + except: + # Other error? + return backend = InvenTreeExchange() print(f"Updating exchange rates from {backend.url}") From 7832ccccc298911dcabe37e823c67491df9bc052 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 28 May 2021 12:54:55 +1000 Subject: [PATCH 35/37] Check if database tables are ready --- InvenTree/InvenTree/tasks.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 994a344cc9..d45df99152 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -168,7 +168,7 @@ def update_exchange_rates(): try: from InvenTree.exchange import InvenTreeExchange - from djmoney.contrib.exchange.models import Rate + from djmoney.contrib.exchange.models import ExchangeBackend, Rate from django.conf import settings except AppRegistryNotReady: # Apps not yet loaded! @@ -177,6 +177,16 @@ def update_exchange_rates(): # Other error? return + # 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 + backend = InvenTreeExchange() print(f"Updating exchange rates from {backend.url}") From be6e2aa2769c1d21f30c7b1e9efc5da671380f36 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 28 May 2021 13:02:34 +1000 Subject: [PATCH 36/37] Better exception handling --- InvenTree/InvenTree/apps.py | 10 ++++++++-- InvenTree/InvenTree/ready.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 465dc2087b..aeddb714a0 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -6,7 +6,7 @@ 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,7 +20,9 @@ class InvenTreeConfig(AppConfig): if canAppAccessDatabase(): self.start_background_tasks() - self.update_exchange_rates() + + if not isInTestMode(): + self.update_exchange_rates() def start_background_tasks(self): @@ -95,5 +97,9 @@ class InvenTreeConfig(AppConfig): 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/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. From b7163124385044508e89fa4fd078f8fabeaa8f99 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 28 May 2021 13:24:18 +1000 Subject: [PATCH 37/37] Ignore actions for l10 branches --- .github/workflows/coverage.yaml | 9 ++++++++- .github/workflows/mysql.yaml | 9 ++++++++- .github/workflows/postgresql.yaml | 9 ++++++++- .github/workflows/style.yaml | 9 ++++++++- .github/workflows/translations.yml | 1 - 5 files changed, 32 insertions(+), 5 deletions(-) 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: