mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into price-history
This commit is contained in:
commit
9cc81df276
9
.github/workflows/coverage.yaml
vendored
9
.github/workflows/coverage.yaml
vendored
@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
name: SQLite
|
name: SQLite
|
||||||
|
|
||||||
on: ["push", "pull_request"]
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- l10*
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- l10*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
9
.github/workflows/mysql.yaml
vendored
9
.github/workflows/mysql.yaml
vendored
@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
name: MySQL
|
name: MySQL
|
||||||
|
|
||||||
on: ["push", "pull_request"]
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- l10*
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- l10*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
9
.github/workflows/postgresql.yaml
vendored
9
.github/workflows/postgresql.yaml
vendored
@ -2,7 +2,14 @@
|
|||||||
|
|
||||||
name: PostgreSQL
|
name: PostgreSQL
|
||||||
|
|
||||||
on: ["push", "pull_request"]
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- l10*
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- l10*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
9
.github/workflows/style.yaml
vendored
9
.github/workflows/style.yaml
vendored
@ -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:
|
||||||
|
1
.github/workflows/translations.yml
vendored
1
.github/workflows/translations.yml
vendored
@ -5,7 +5,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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.
|
||||||
|
@ -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', {})
|
||||||
|
@ -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):
|
||||||
|
@ -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')
|
||||||
|
@ -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'),
|
||||||
|
|
||||||
|
@ -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 """
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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():
|
||||||
|
@ -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')
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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.')
|
||||||
|
@ -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:
|
||||||
|
@ -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 = [
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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:
|
||||||
|
@ -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 %}",
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
55
InvenTree/templates/InvenTree/settings/currencies.html
Normal file
55
InvenTree/templates/InvenTree/settings/currencies.html
Normal 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 %}
|
@ -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 %}
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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 \
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user