Merge branch 'inventree:master' into price-history

This commit is contained in:
Matthias Mair 2021-05-28 12:14:03 +02:00
commit 9cc81df276
36 changed files with 571 additions and 113 deletions

View File

@ -2,7 +2,14 @@
name: SQLite name: SQLite
on: ["push", "pull_request"] on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs: jobs:

View File

@ -2,7 +2,14 @@
name: MySQL name: MySQL
on: ["push", "pull_request"] on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs: jobs:

View File

@ -2,7 +2,14 @@
name: PostgreSQL name: PostgreSQL
on: ["push", "pull_request"] on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs: jobs:

View File

@ -1,6 +1,13 @@
name: Style Checks name: Style Checks
on: ["push", "pull_request"] on:
push:
branches-ignore:
- l10*
pull_request:
branches-ignore:
- l10*
jobs: jobs:
style: style:

View File

@ -5,7 +5,6 @@ on:
branches: branches:
- master - master
jobs: jobs:
build: build:

View File

@ -4,8 +4,9 @@ import logging
from django.apps import AppConfig from django.apps import AppConfig
from django.core.exceptions import AppRegistryNotReady 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 import InvenTree.tasks
@ -20,6 +21,9 @@ class InvenTreeConfig(AppConfig):
if canAppAccessDatabase(): if canAppAccessDatabase():
self.start_background_tasks() self.start_background_tasks()
if not isInTestMode():
self.update_exchange_rates()
def start_background_tasks(self): def start_background_tasks(self):
try: try:
@ -49,3 +53,53 @@ class InvenTreeConfig(AppConfig):
'InvenTree.tasks.update_exchange_rates', 'InvenTree.tasks.update_exchange_rates',
schedule_type=Schedule.DAILY, schedule_type=Schedule.DAILY,
) )
def update_exchange_rates(self):
"""
Update exchange rates each time the server is started, *if*:
a) Have not been updated recently (one day or less)
b) The base exchange rate has been altered
"""
try:
from djmoney.contrib.exchange.models import ExchangeBackend
from datetime import datetime, timedelta
from InvenTree.tasks import update_exchange_rates
except AppRegistryNotReady:
pass
base_currency = settings.BASE_CURRENCY
update = False
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
last_update = backend.last_update
if last_update is not None:
delta = datetime.now().date() - last_update.date()
if delta > timedelta(days=1):
print(f"Last update was {last_update}")
update = True
else:
# Never been updated
print("Exchange backend has never been updated")
update = True
# Backend currency has changed?
if not base_currency == backend.base_currency:
print(f"Base currency changed from {backend.base_currency} to {base_currency}")
update = True
except (ExchangeBackend.DoesNotExist):
print("Exchange backend not found - updating")
update = True
except:
# Some other error - potentially the tables are not ready yet
return
if update:
update_exchange_rates()

View File

@ -1,21 +1,29 @@
from djmoney.contrib.exchange.backends.base import BaseExchangeBackend from django.conf import settings as inventree_settings
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
class InvenTreeManualExchangeBackend(BaseExchangeBackend): class InvenTreeExchange(SimpleExchangeBackend):
""" """
Backend for manually updating currency exchange rates Backend for automatically updating currency exchange rates.
See the documentation for django-money: https://github.com/django-money/django-money Uses the exchangerate.host service API
Specifically: https://github.com/django-money/django-money/tree/master/djmoney/contrib/exchange/backends
""" """
name = "inventree" name = "InvenTreeExchange"
url = None
def get_rates(self, **kwargs): def __init__(self):
""" self.url = "https://api.exchangerate.host/latest"
Do not get any rates...
"""
return {} super().__init__()
def get_params(self):
# No API key is required
return {
}
def update_rates(self, base_currency=inventree_settings.BASE_CURRENCY):
symbols = ','.join(inventree_settings.CURRENCIES)
super().update_rates(base=base_currency, symbols=symbols)

View File

@ -7,10 +7,12 @@ from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django import forms from django import forms
from django.contrib.auth.models import User
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field from crispy_forms.layout import Layout, Field
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div
from django.contrib.auth.models import User
from common.models import ColorTheme from common.models import ColorTheme
from part.models import PartCategory from part.models import PartCategory

View File

@ -8,7 +8,7 @@ import json
import os.path import os.path
from PIL import Image from PIL import Image
from decimal import Decimal from decimal import Decimal, InvalidOperation
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
@ -606,3 +606,19 @@ def getNewestMigrationFile(app, exclude_extension=True):
newest_file = newest_file.replace('.py', '') newest_file = newest_file.replace('.py', '')
return newest_file 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()

View File

@ -1,6 +1,17 @@
import sys import sys
def isInTestMode():
"""
Returns True if the database is in testing mode
"""
if 'test' in sys.argv:
return True
return False
def canAppAccessDatabase(): def canAppAccessDatabase():
""" """
Returns True if the apps.py file can access database records. Returns True if the apps.py file can access database records.

View File

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

View File

@ -163,30 +163,41 @@ def check_for_updates():
def update_exchange_rates(): 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: try:
import common.models from InvenTree.exchange import InvenTreeExchange
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from django.conf import settings from django.conf import settings
from djmoney.contrib.exchange.backends import FixerBackend
except AppRegistryNotReady: except AppRegistryNotReady:
# Apps not yet loaded! # Apps not yet loaded!
return return
except:
fixer_api_key = common.models.InvenTreeSetting.get_setting('INVENTREE_FIXER_API_KEY', '').strip() # Other error?
if not fixer_api_key:
# API key not provided
return return
backend = FixerBackend(access_key=fixer_api_key) # Test to see if the database is ready yet
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
except ExchangeBackend.DoesNotExist:
pass
except:
# Some other error
print("Database not ready")
return
currencies = ','.join(settings.CURRENCIES) backend = InvenTreeExchange()
print(f"Updating exchange rates from {backend.url}")
base = settings.BASE_CURRENCY base = settings.BASE_CURRENCY
backend.update_rates(base_currency=base, symbols=currencies) print(f"Using base currency '{base}'")
backend.update_rates(base_currency=base)
# Remove any exchange rates which are not in the provided currencies
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=settings.CURRENCIES).delete()
def send_email(subject, body, recipients, from_email=None): def send_email(subject, body, recipients, from_email=None):

View File

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

View File

@ -39,6 +39,7 @@ from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView from .views import SettingsView, EditUserView, SetPasswordView
from .views import CurrencySettingsView, CurrencyRefreshView
from .views import AppearanceSelectView, SettingCategorySelectView from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView from .views import DynamicJsView
@ -82,14 +83,16 @@ settings_urls = [
url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'), url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
url(r'^i18n/?', include('django.conf.urls.i18n')), 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'^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'^report/', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'), 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'^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'^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'^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'^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'^sales-order/', SettingsView.as_view(template_name='InvenTree/settings/so.html'), name='settings-so'),
url(r'^currencies/', CurrencySettingsView.as_view(), name='settings-currencies'),
url(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),
url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'), url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'),

View File

@ -12,18 +12,23 @@ from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.http import JsonResponse, HttpResponseRedirect from django.http import JsonResponse, HttpResponseRedirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.views import View from django.views import View
from django.views.generic import ListView, DetailView, CreateView, FormView, DeleteView, UpdateView 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 part.models import Part, PartCategory
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
from common.models import InvenTreeSetting, ColorTheme from common.models import InvenTreeSetting, ColorTheme
from users.models import check_user_role, RuleSet from users.models import check_user_role, RuleSet
import InvenTree.tasks
from .forms import DeleteForm, EditUserForm, SetPasswordForm from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .forms import ColorThemeSelectForm, SettingCategorySelectForm from .forms import ColorThemeSelectForm, SettingCategorySelectForm
from .helpers import str2bool from .helpers import str2bool
@ -769,6 +774,51 @@ class SettingsView(TemplateView):
return ctx return ctx
class CurrencyRefreshView(RedirectView):
"""
POST endpoint to refresh / update exchange rates
"""
url = reverse_lazy("settings-currencies")
def post(self, request, *args, **kwargs):
"""
On a POST request we will attempt to refresh the exchange rates
"""
# Will block for a little bit
InvenTree.tasks.update_exchange_rates()
return self.get(request, *args, **kwargs)
class CurrencySettingsView(TemplateView):
"""
View for configuring currency settings
"""
template_name = "InvenTree/settings/currencies.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs).copy()
ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
ctx["base_currency"] = settings.BASE_CURRENCY
ctx["currencies"] = settings.CURRENCIES
ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
# When were the rates last updated?
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
ctx["rates_updated"] = backend.last_update
except:
ctx["rates_updated"] = None
return ctx
class AppearanceSelectView(FormView): class AppearanceSelectView(FormView):
""" View for selecting a color theme """ """ View for selecting a color theme """

View File

@ -5,14 +5,15 @@ Django forms for interacting with common objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from decimal import Decimal, InvalidOperation
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from djmoney.forms.fields import MoneyField from djmoney.forms.fields import MoneyField
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.helpers import clean_decimal
from common.settings import currency_code_default
from .files import FileManager from .files import FileManager
from .models import InvenTreeSetting from .models import InvenTreeSetting
@ -119,21 +120,6 @@ class MatchItem(forms.Form):
super().__init__(*args, **kwargs) 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 # Setup FileManager
file_manager.setup() file_manager.setup()
@ -160,7 +146,7 @@ class MatchItem(forms.Form):
'type': 'number', 'type': 'number',
'min': '0', 'min': '0',
'step': 'any', 'step': 'any',
'value': clean(row.get('quantity', '')), 'value': clean_decimal(row.get('quantity', '')),
}) })
) )
@ -198,11 +184,11 @@ class MatchItem(forms.Form):
if 'price' in col_guess.lower(): if 'price' in col_guess.lower():
self.fields[field_name] = MoneyField( self.fields[field_name] = MoneyField(
label=_(col_guess), label=_(col_guess),
default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'), default_currency=currency_code_default(),
decimal_places=5, decimal_places=5,
max_digits=19, max_digits=19,
required=False, required=False,
default_amount=clean(value), default_amount=clean_decimal(value),
) )
else: else:
self.fields[field_name] = forms.CharField( self.fields[field_name] = forms.CharField(

View File

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

View File

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

View File

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

View File

@ -11,9 +11,6 @@ from .models import Company, Contact, ManufacturerPart, SupplierPart
from .models import rename_company_image from .models import rename_company_image
from part.models import Part from part.models import Part
from InvenTree.exchange import InvenTreeManualExchangeBackend
from djmoney.contrib.exchange.models import Rate
class CompanySimpleTest(TestCase): class CompanySimpleTest(TestCase):
@ -41,14 +38,6 @@ class CompanySimpleTest(TestCase):
self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS') self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312') self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
InvenTreeManualExchangeBackend().update_rates()
Rate.objects.create(
currency='AUD',
value='1.35',
backend_id='inventree',
)
def test_company_model(self): def test_company_model(self):
c = Company.objects.get(name='ABC Co.') c = Company.objects.get(name='ABC Co.')
self.assertEqual(c.name, 'ABC Co.') self.assertEqual(c.name, 'ABC Co.')

View File

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

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.http import JsonResponse 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 django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
@ -15,6 +15,10 @@ from rest_framework.response import Response
from rest_framework import filters, serializers from rest_framework import filters, serializers
from rest_framework import generics 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.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
@ -24,6 +28,7 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak from .models import PartSellPriceBreak
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from common.models import InvenTreeSetting
from build.models import Build from build.models import Build
from . import serializers as part_serializers from . import serializers as part_serializers
@ -877,6 +882,60 @@ class BomList(generics.ListCreateAPIView):
else: else:
queryset = queryset.exclude(pk__in=pks) queryset = queryset.exclude(pk__in=pks)
# Annotate with purchase prices
queryset = queryset.annotate(
purchase_price_min=Min('sub_part__stock_items__purchase_price'),
purchase_price_max=Max('sub_part__stock_items__purchase_price'),
purchase_price_avg=Avg('sub_part__stock_items__purchase_price'),
)
# Get values for currencies
currencies = queryset.annotate(
purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'),
).values('pk', 'sub_part', 'purchase_price_currency')
def convert_price(price, currency, decimal_places=4):
""" Convert price field, returns Money field """
price_adjusted = None
# Get default currency from settings
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
if price:
if currency and default_currency:
try:
# Get adjusted price
price_adjusted = convert_money(Money(price, currency), default_currency)
except MissingRate:
# No conversion rate set
price_adjusted = Money(price, currency)
else:
# Currency exists
if currency:
price_adjusted = Money(price, currency)
# Default currency exists
if default_currency:
price_adjusted = Money(price, default_currency)
if price_adjusted and decimal_places:
price_adjusted.decimal_places = decimal_places
return price_adjusted
# Convert prices to default currency (using backend conversion rates)
for bom_item in queryset:
# Find associated currency (select first found)
purchase_price_currency = None
for currency_item in currencies:
if currency_item['pk'] == bom_item.pk and currency_item['sub_part'] == bom_item.sub_part.pk:
purchase_price_currency = currency_item['purchase_price_currency']
break
# Convert prices
bom_item.purchase_price_min = convert_price(bom_item.purchase_price_min, purchase_price_currency)
bom_item.purchase_price_max = convert_price(bom_item.purchase_price_max, purchase_price_currency)
bom_item.purchase_price_avg = convert_price(bom_item.purchase_price_avg, purchase_price_currency)
return queryset return queryset
filter_backends = [ filter_backends = [

View File

@ -1861,6 +1861,59 @@ class Part(MPTTModel):
return self.get_descendants(include_self=False) 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): def get_related_parts(self):
""" Return list of tuples for all related parts: """ Return list of tuples for all related parts:
- first value is PartRelated object - first value is PartRelated object

View File

@ -12,6 +12,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField
from stock.models import StockItem from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory, from .models import (BomItem, Part, PartAttachment, PartCategory,
@ -367,6 +368,14 @@ class BomItemSerializer(InvenTreeModelSerializer):
validated = serializers.BooleanField(read_only=True, source='is_line_valid') validated = serializers.BooleanField(read_only=True, source='is_line_valid')
purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True)
purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True)
purchase_price_avg = serializers.SerializerMethodField()
purchase_price_range = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested. # part_detail and sub_part_detail serializers are only included if requested.
# This saves a bunch of database requests # This saves a bunch of database requests
@ -394,6 +403,53 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks') queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset return queryset
def get_purchase_price_range(self, obj):
""" Return purchase price range """
try:
purchase_price_min = obj.purchase_price_min
except AttributeError:
return None
try:
purchase_price_max = obj.purchase_price_max
except AttributeError:
return None
if purchase_price_min and not purchase_price_max:
# Get price range
purchase_price_range = str(purchase_price_max)
elif not purchase_price_min and purchase_price_max:
# Get price range
purchase_price_range = str(purchase_price_max)
elif purchase_price_min and purchase_price_max:
# Get price range
if purchase_price_min >= purchase_price_max:
# If min > max: use min only
purchase_price_range = str(purchase_price_min)
else:
purchase_price_range = str(purchase_price_min) + " - " + str(purchase_price_max)
else:
purchase_price_range = '-'
return purchase_price_range
def get_purchase_price_avg(self, obj):
""" Return purchase price average """
try:
purchase_price_avg = obj.purchase_price_avg
except AttributeError:
return None
if purchase_price_avg:
# Get string representation of price average
purchase_price_avg = str(purchase_price_avg)
else:
purchase_price_avg = '-'
return purchase_price_avg
class Meta: class Meta:
model = BomItem model = BomItem
fields = [ fields = [
@ -410,6 +466,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
'sub_part_detail', 'sub_part_detail',
# 'price_range', # 'price_range',
'validated', 'validated',
'purchase_price_min',
'purchase_price_max',
'purchase_price_avg',
'purchase_price_range',
] ]

View File

@ -102,6 +102,11 @@
</div> </div>
<div class='info-messages'> <div class='info-messages'>
{% if part.virtual %}
<div class='alert alert-warning alert-block'>
{% trans "This is a virtual part" %}
</div>
{% endif %}
{% if part.variant_of %} {% if part.variant_of %}
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
{% object_link 'part-variants' part.variant_of.id part.variant_of.full_name as link %} {% object_link 'part-variants' part.variant_of.id part.variant_of.full_name as link %}

View File

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

View File

@ -139,7 +139,7 @@
<div class='btn-group'> <div class='btn-group'>
<button id='stock-edit-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-tools'></span> <span class='caret'></span></button> <button id='stock-edit-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-tools'></span> <span class='caret'></span></button>
<ul class='dropdown-menu' role='menu'> <ul class='dropdown-menu' role='menu'>
{% if item.part.has_variants %} {% if item.part.can_convert %}
<li><a href='#' id='stock-convert' title='{% trans "Convert to variant" %}'><span class='fas fa-screwdriver'></span> {% trans "Convert to variant" %}</a></li> <li><a href='#' id='stock-convert' title='{% trans "Convert to variant" %}'><span class='fas fa-screwdriver'></span> {% trans "Convert to variant" %}</a></li>
{% endif %} {% endif %}
{% if roles.stock.add %} {% if roles.stock.add %}
@ -551,7 +551,7 @@ $("#stock-assign-to-customer").click(function() {
); );
}); });
{% if item.part.has_variants %} {% if item.part.can_convert %}
$("#stock-convert").click(function() { $("#stock-convert").click(function() {
launchModalForm("{% url 'stock-item-convert' item.id %}", launchModalForm("{% url 'stock-item-convert' item.id %}",
{ {

View File

@ -1373,7 +1373,7 @@ class StockItemConvert(AjaxUpdateView):
form = super().get_form() form = super().get_form()
item = self.get_object() item = self.get_object()
form.fields['part'].queryset = item.part.get_all_variants() form.fields['part'].queryset = item.part.get_conversion_options()
return form return form

View File

@ -0,0 +1,55 @@
{% 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 %}
<table class='table table-striped table-condensed'>
<tbody>
<tr>
<th>{% trans "Base Currency" %}</th>
<th>{{ base_currency }}</th>
</tr>
<tr>
<th colspan='2'>{% trans "Exchange Rates" %}</th>
</tr>
{% for rate in rates %}
<tr>
<td>{{ rate.currency }}</td>
<td>{{ rate.value }}</td>
</tr>
{% endfor %}
<tr>
<th>
{% trans "Last Update" %}
</th>
<td>
{% if rates_updated %}
{{ rates_updated }}
{% else %}
<i>{% trans "Never" %}</i>
{% endif %}
<form action='{% url "settings-currencies-refresh" %}' method='post'>
<div id='refresh-rates-form'>
{% csrf_token %}
<button type='submit' id='update-rates' class='btn btn-default float-right'>{% trans "Update Now" %}</button>
</div>
</form>
</td>
</tr>
</tbody>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
{% endblock %}

View File

@ -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_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_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_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" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %}
</tbody> </tbody>
</table> </table>
@ -33,4 +31,4 @@
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}

View File

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

View File

@ -243,6 +243,22 @@ function loadBomTable(table, options) {
} }
}); });
cols.push(
{
field: 'purchase_price_range',
title: '{% trans "Purchase Price Range" %}',
searchable: false,
sortable: true,
});
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, // TODO - Re-introduce the pricing column at a later stage,

View File

@ -41,9 +41,10 @@ LABEL org.label-schema.schema-version="1.0" \
# Create user account # Create user account
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
WORKDIR ${INVENTREE_HOME} WORKDIR ${INVENTREE_HOME}
RUN mkdir ${INVENTREE_STATIC_ROOT} RUN mkdir -p ${INVENTREE_STATIC_ROOT}
# Install required system packages # Install required system packages
RUN apk add --no-cache git make bash \ RUN apk add --no-cache git make bash \

View File

@ -3,12 +3,12 @@
# Create required directory structure (if it does not already exist) # Create required directory structure (if it does not already exist)
if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
echo "Creating directory $INVENTREE_STATIC_ROOT" echo "Creating directory $INVENTREE_STATIC_ROOT"
mkdir $INVENTREE_STATIC_ROOT mkdir -p $INVENTREE_STATIC_ROOT
fi fi
if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
echo "Creating directory $INVENTREE_MEDIA_ROOT" echo "Creating directory $INVENTREE_MEDIA_ROOT"
mkdir $INVENTREE_MEDIA_ROOT mkdir -p $INVENTREE_MEDIA_ROOT
fi fi
# Check if "config.yaml" has been copied into the correct location # Check if "config.yaml" has been copied into the correct location

View File

@ -3,12 +3,12 @@
# Create required directory structure (if it does not already exist) # Create required directory structure (if it does not already exist)
if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
echo "Creating directory $INVENTREE_STATIC_ROOT" echo "Creating directory $INVENTREE_STATIC_ROOT"
mkdir $INVENTREE_STATIC_ROOT mkdir -p $INVENTREE_STATIC_ROOT
fi fi
if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
echo "Creating directory $INVENTREE_MEDIA_ROOT" echo "Creating directory $INVENTREE_MEDIA_ROOT"
mkdir $INVENTREE_MEDIA_ROOT mkdir -p $INVENTREE_MEDIA_ROOT
fi fi
# Check if "config.yaml" has been copied into the correct location # Check if "config.yaml" has been copied into the correct location

View File

@ -25,6 +25,7 @@ django-stdimage==5.1.1 # Advanced ImageField management
django-weasyprint==1.0.1 # HTML PDF export django-weasyprint==1.0.1 # HTML PDF export
django-debug-toolbar==2.2 # Debug / profiling toolbar django-debug-toolbar==2.2 # Debug / profiling toolbar
django-admin-shell==0.1.2 # Python shell for the admin interface django-admin-shell==0.1.2 # Python shell for the admin interface
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
django-money==1.1 # Django app for currency management django-money==1.1 # Django app for currency management
certifi # Certifi is (most likely) installed through one of the requirements above certifi # Certifi is (most likely) installed through one of the requirements above
django-error-report==0.2.0 # Error report viewer for the admin interface django-error-report==0.2.0 # Error report viewer for the admin interface