mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 15:15:42 +00:00 
			
		
		
		
	Merge remote-tracking branch 'inventree/master'
This commit is contained in:
		
							
								
								
									
										9
									
								
								.github/workflows/coverage.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/coverage.yaml
									
									
									
									
										vendored
									
									
								
							@@ -2,7 +2,14 @@
 | 
			
		||||
 | 
			
		||||
name: SQLite
 | 
			
		||||
 | 
			
		||||
on: ["push", "pull_request"]
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								.github/workflows/mysql.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/mysql.yaml
									
									
									
									
										vendored
									
									
								
							@@ -2,7 +2,14 @@
 | 
			
		||||
 | 
			
		||||
name: MySQL
 | 
			
		||||
 | 
			
		||||
on: ["push", "pull_request"]
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								.github/workflows/postgresql.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/postgresql.yaml
									
									
									
									
										vendored
									
									
								
							@@ -2,7 +2,14 @@
 | 
			
		||||
 | 
			
		||||
name: PostgreSQL
 | 
			
		||||
 | 
			
		||||
on: ["push", "pull_request"]
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								.github/workflows/style.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/style.yaml
									
									
									
									
										vendored
									
									
								
							@@ -1,6 +1,13 @@
 | 
			
		||||
name: Style Checks
 | 
			
		||||
 | 
			
		||||
on: ["push", "pull_request"]
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
  pull_request:
 | 
			
		||||
    branches-ignore:
 | 
			
		||||
      - l10*
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  style:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/workflows/translations.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/translations.yml
									
									
									
									
										vendored
									
									
								
							@@ -5,7 +5,6 @@ on:
 | 
			
		||||
    branches:
 | 
			
		||||
      - master
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build:
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,8 +4,9 @@ import logging
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
from django.core.exceptions import AppRegistryNotReady
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from InvenTree.ready import canAppAccessDatabase
 | 
			
		||||
from InvenTree.ready import isInTestMode, canAppAccessDatabase
 | 
			
		||||
import InvenTree.tasks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -20,6 +21,9 @@ class InvenTreeConfig(AppConfig):
 | 
			
		||||
        if canAppAccessDatabase():
 | 
			
		||||
            self.start_background_tasks()
 | 
			
		||||
 | 
			
		||||
            if not isInTestMode():
 | 
			
		||||
                self.update_exchange_rates()
 | 
			
		||||
 | 
			
		||||
    def start_background_tasks(self):
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
@@ -49,3 +53,53 @@ class InvenTreeConfig(AppConfig):
 | 
			
		||||
            'InvenTree.tasks.update_exchange_rates',
 | 
			
		||||
            schedule_type=Schedule.DAILY,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def update_exchange_rates(self):
 | 
			
		||||
        """
 | 
			
		||||
        Update exchange rates each time the server is started, *if*:
 | 
			
		||||
 | 
			
		||||
        a) Have not been updated recently (one day or less)
 | 
			
		||||
        b) The base exchange rate has been altered
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            from djmoney.contrib.exchange.models import ExchangeBackend
 | 
			
		||||
            from datetime import datetime, timedelta
 | 
			
		||||
            from InvenTree.tasks import update_exchange_rates
 | 
			
		||||
        except AppRegistryNotReady:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        base_currency = settings.BASE_CURRENCY
 | 
			
		||||
 | 
			
		||||
        update = False
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
 | 
			
		||||
 | 
			
		||||
            last_update = backend.last_update
 | 
			
		||||
 | 
			
		||||
            if last_update is not None:
 | 
			
		||||
                delta = datetime.now().date() - last_update.date()
 | 
			
		||||
                if delta > timedelta(days=1):
 | 
			
		||||
                    print(f"Last update was {last_update}")
 | 
			
		||||
                    update = True
 | 
			
		||||
            else:
 | 
			
		||||
                # Never been updated
 | 
			
		||||
                print("Exchange backend has never been updated")
 | 
			
		||||
                update = True
 | 
			
		||||
 | 
			
		||||
            # Backend currency has changed?
 | 
			
		||||
            if not base_currency == backend.base_currency:
 | 
			
		||||
                print(f"Base currency changed from {backend.base_currency} to {base_currency}")
 | 
			
		||||
                update = True
 | 
			
		||||
 | 
			
		||||
        except (ExchangeBackend.DoesNotExist):
 | 
			
		||||
            print("Exchange backend not found - updating")
 | 
			
		||||
            update = True
 | 
			
		||||
 | 
			
		||||
        except:
 | 
			
		||||
            # Some other error - potentially the tables are not ready yet
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if update:
 | 
			
		||||
            update_exchange_rates()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,120 +1,29 @@
 | 
			
		||||
from django.conf import settings as inventree_settings
 | 
			
		||||
 | 
			
		||||
from djmoney.contrib.exchange.backends.base import BaseExchangeBackend
 | 
			
		||||
from djmoney.contrib.exchange.models import Rate
 | 
			
		||||
 | 
			
		||||
from common.models import InvenTreeSetting
 | 
			
		||||
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_exchange_rate_backend():
 | 
			
		||||
    """ Return the exchange rate backend set by user """
 | 
			
		||||
 | 
			
		||||
    custom = InvenTreeSetting.get_setting('CUSTOM_EXCHANGE_RATES', False)
 | 
			
		||||
 | 
			
		||||
    if custom:
 | 
			
		||||
        return InvenTreeManualExchangeBackend()
 | 
			
		||||
    else:
 | 
			
		||||
        return ExchangeRateHostBackend()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InvenTreeManualExchangeBackend(BaseExchangeBackend):
 | 
			
		||||
class InvenTreeExchange(SimpleExchangeBackend):
 | 
			
		||||
    """
 | 
			
		||||
    Backend for manually updating currency exchange rates
 | 
			
		||||
    Backend for automatically updating currency exchange rates.
 | 
			
		||||
 | 
			
		||||
    See the documentation for django-money: https://github.com/django-money/django-money
 | 
			
		||||
 | 
			
		||||
    Specifically: https://github.com/django-money/django-money/tree/master/djmoney/contrib/exchange/backends
 | 
			
		||||
    Uses the exchangerate.host service API
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    name = 'inventree'
 | 
			
		||||
    url = None
 | 
			
		||||
    custom_rates = True
 | 
			
		||||
    base_currency = None
 | 
			
		||||
    currencies = []
 | 
			
		||||
 | 
			
		||||
    def update_default_currency(self):
 | 
			
		||||
        """ Update to base currency """
 | 
			
		||||
 | 
			
		||||
        self.base_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY', 'USD')
 | 
			
		||||
 | 
			
		||||
    def __init__(self, url=None):
 | 
			
		||||
        """ Overrides init to update url, base currency and currencies """
 | 
			
		||||
 | 
			
		||||
        self.url = url
 | 
			
		||||
 | 
			
		||||
        self.update_default_currency()
 | 
			
		||||
 | 
			
		||||
        # Update name
 | 
			
		||||
        self.name = self.name + '-' + self.base_currency.lower()
 | 
			
		||||
 | 
			
		||||
        self.currencies = inventree_settings.CURRENCIES
 | 
			
		||||
 | 
			
		||||
        super().__init__()
 | 
			
		||||
 | 
			
		||||
    def get_rates(self, **kwargs):
 | 
			
		||||
        """ Returns a mapping <currency>: <rate> """
 | 
			
		||||
 | 
			
		||||
        return kwargs.get('rates', {})
 | 
			
		||||
 | 
			
		||||
    def get_stored_rates(self):
 | 
			
		||||
        """ Returns stored rate for specified backend and base currency """
 | 
			
		||||
 | 
			
		||||
        stored_rates = {}
 | 
			
		||||
 | 
			
		||||
        stored_rates_obj = Rate.objects.all().prefetch_related('backend')
 | 
			
		||||
 | 
			
		||||
        for rate in stored_rates_obj:
 | 
			
		||||
            # Find match for backend and base currency
 | 
			
		||||
            if rate.backend.name == self.name and rate.backend.base_currency == self.base_currency:
 | 
			
		||||
                # print(f'{rate.currency} | {rate.value} | {rate.backend} | {rate.backend.base_currency}')
 | 
			
		||||
                stored_rates[rate.currency] = rate.value
 | 
			
		||||
 | 
			
		||||
        return stored_rates
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ExchangeRateHostBackend(InvenTreeManualExchangeBackend):
 | 
			
		||||
    """
 | 
			
		||||
    Backend for https://exchangerate.host/
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    name = "exchangerate.host"
 | 
			
		||||
    name = "InvenTreeExchange"
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.url = "https://api.exchangerate.host/latest"
 | 
			
		||||
 | 
			
		||||
        self.custom_rates = False
 | 
			
		||||
 | 
			
		||||
        super().__init__(url=self.url)
 | 
			
		||||
        super().__init__()
 | 
			
		||||
 | 
			
		||||
    def get_params(self):
 | 
			
		||||
        # No API key is required
 | 
			
		||||
        return {}
 | 
			
		||||
        return {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def update_rates(self, base_currency=None):
 | 
			
		||||
        """ Override update_rates method using currencies found in the settings
 | 
			
		||||
        """
 | 
			
		||||
    def update_rates(self, base_currency=inventree_settings.BASE_CURRENCY):
 | 
			
		||||
 | 
			
		||||
        if base_currency:
 | 
			
		||||
            self.base_currency = base_currency
 | 
			
		||||
        else:
 | 
			
		||||
            self.update_default_currency()
 | 
			
		||||
        
 | 
			
		||||
        symbols = ','.join(self.currencies)
 | 
			
		||||
        symbols = ','.join(inventree_settings.CURRENCIES)
 | 
			
		||||
 | 
			
		||||
        super().update_rates(base_currency=self.base_currency, symbols=symbols)
 | 
			
		||||
 | 
			
		||||
    def get_rates(self, **params):
 | 
			
		||||
        """ Returns a mapping <currency>: <rate> """
 | 
			
		||||
 | 
			
		||||
        # Set base currency
 | 
			
		||||
        params.update(base=self.base_currency)
 | 
			
		||||
 | 
			
		||||
        response = self.get_response(**params)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            return self.parse_json(response)['rates']
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            # API response did not contain any rate
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
        return {}
 | 
			
		||||
        super().update_rates(base=base_currency, symbols=symbols)
 | 
			
		||||
 
 | 
			
		||||
@@ -16,8 +16,6 @@ from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppende
 | 
			
		||||
from common.models import ColorTheme
 | 
			
		||||
from part.models import PartCategory
 | 
			
		||||
 | 
			
		||||
from .exchange import InvenTreeManualExchangeBackend
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HelperForm(forms.ModelForm):
 | 
			
		||||
    """ Provides simple integration of crispy_forms extension. """
 | 
			
		||||
@@ -240,35 +238,3 @@ class SettingCategorySelectForm(forms.ModelForm):
 | 
			
		||||
                css_class='row',
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SettingExchangeRatesForm(forms.Form):
 | 
			
		||||
    """ Form for displaying and setting currency exchange rates manually """
 | 
			
		||||
        
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        exchange_rate_backend = InvenTreeManualExchangeBackend()
 | 
			
		||||
 | 
			
		||||
        # Update default currency (in case it has changed)
 | 
			
		||||
        exchange_rate_backend.update_default_currency()
 | 
			
		||||
 | 
			
		||||
        for currency in exchange_rate_backend.currencies:
 | 
			
		||||
            if currency != exchange_rate_backend.base_currency:
 | 
			
		||||
                # Set field name
 | 
			
		||||
                field_name = currency
 | 
			
		||||
                # Set field input box
 | 
			
		||||
                self.fields[field_name] = forms.CharField(
 | 
			
		||||
                    label=field_name,
 | 
			
		||||
                    required=False,
 | 
			
		||||
                    widget=forms.NumberInput(attrs={
 | 
			
		||||
                        'name': field_name,
 | 
			
		||||
                        'class': 'numberinput',
 | 
			
		||||
                        'style': 'width: 200px;',
 | 
			
		||||
                        'type': 'number',
 | 
			
		||||
                        'min': '0',
 | 
			
		||||
                        'step': 'any',
 | 
			
		||||
                        'value': 0,
 | 
			
		||||
                    })
 | 
			
		||||
                )
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,17 @@
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def isInTestMode():
 | 
			
		||||
    """
 | 
			
		||||
    Returns True if the database is in testing mode
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    if 'test' in sys.argv:
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def canAppAccessDatabase():
 | 
			
		||||
    """
 | 
			
		||||
    Returns True if the apps.py file can access database records.
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,8 @@ import shutil
 | 
			
		||||
import sys
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
import moneyed
 | 
			
		||||
 | 
			
		||||
import yaml
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
@@ -513,6 +515,20 @@ CURRENCIES = CONFIG.get(
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# 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)
 | 
			
		||||
 | 
			
		||||
BASE_CURRENCY = get_setting(
 | 
			
		||||
    'INVENTREE_BASE_CURRENCY',
 | 
			
		||||
    CONFIG.get('base_currency', 'USD')
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# Custom currency exchange backend
 | 
			
		||||
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'
 | 
			
		||||
 | 
			
		||||
# Extract email settings from the config file
 | 
			
		||||
email_config = CONFIG.get('email', {})
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -167,21 +167,38 @@ def update_exchange_rates():
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        import common.models
 | 
			
		||||
        from InvenTree.exchange import ExchangeRateHostBackend
 | 
			
		||||
        from InvenTree.exchange import InvenTreeExchange
 | 
			
		||||
        from djmoney.contrib.exchange.models import ExchangeBackend, Rate
 | 
			
		||||
        from django.conf import settings
 | 
			
		||||
    except AppRegistryNotReady:
 | 
			
		||||
        # Apps not yet loaded!
 | 
			
		||||
        return
 | 
			
		||||
    except:
 | 
			
		||||
        # Other error?
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    backend = ExchangeRateHostBackend()
 | 
			
		||||
    # Test to see if the database is ready yet
 | 
			
		||||
    try:
 | 
			
		||||
        backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
 | 
			
		||||
    except ExchangeBackend.DoesNotExist:
 | 
			
		||||
        pass
 | 
			
		||||
    except:
 | 
			
		||||
        # Some other error
 | 
			
		||||
        print("Database not ready")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    backend = InvenTreeExchange()
 | 
			
		||||
    print(f"Updating exchange rates from {backend.url}")
 | 
			
		||||
 | 
			
		||||
    base = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
 | 
			
		||||
    base = settings.BASE_CURRENCY
 | 
			
		||||
 | 
			
		||||
    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):
 | 
			
		||||
    """
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,12 @@ from django.test import TestCase
 | 
			
		||||
import django.core.exceptions as django_exceptions
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from djmoney.money import Money
 | 
			
		||||
from djmoney.contrib.exchange.models import Rate, convert_money
 | 
			
		||||
from djmoney.contrib.exchange.exceptions import MissingRate
 | 
			
		||||
 | 
			
		||||
from .validators import validate_overage, validate_part_name
 | 
			
		||||
from . import helpers
 | 
			
		||||
from . import version
 | 
			
		||||
@@ -13,6 +19,8 @@ from mptt.exceptions import InvalidMove
 | 
			
		||||
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import InvenTree.tasks
 | 
			
		||||
 | 
			
		||||
from stock.models import StockLocation
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -308,3 +316,46 @@ class TestVersionNumber(TestCase):
 | 
			
		||||
        self.assertTrue(v_c > v_b)
 | 
			
		||||
        self.assertTrue(v_d > v_c)
 | 
			
		||||
        self.assertTrue(v_d > v_a)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyTests(TestCase):
 | 
			
		||||
    """
 | 
			
		||||
    Unit tests for currency / exchange rate functionality
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def test_rates(self):
 | 
			
		||||
 | 
			
		||||
        # Initially, there will not be any exchange rate information
 | 
			
		||||
        rates = Rate.objects.all()
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(rates.count(), 0)
 | 
			
		||||
 | 
			
		||||
        # Without rate information, we cannot convert anything...
 | 
			
		||||
        with self.assertRaises(MissingRate):
 | 
			
		||||
            convert_money(Money(100, 'USD'), 'AUD')
 | 
			
		||||
 | 
			
		||||
        with self.assertRaises(MissingRate):
 | 
			
		||||
            convert_money(Money(100, 'AUD'), 'USD')
 | 
			
		||||
 | 
			
		||||
        currencies = settings.CURRENCIES
 | 
			
		||||
 | 
			
		||||
        InvenTree.tasks.update_exchange_rates()
 | 
			
		||||
 | 
			
		||||
        rates = Rate.objects.all()
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(rates.count(), len(currencies))
 | 
			
		||||
 | 
			
		||||
        # Now that we have some exchange rate information, we can perform conversions
 | 
			
		||||
 | 
			
		||||
        # Forwards
 | 
			
		||||
        convert_money(Money(100, 'USD'), 'AUD')
 | 
			
		||||
 | 
			
		||||
        # Backwards
 | 
			
		||||
        convert_money(Money(100, 'AUD'), 'USD')
 | 
			
		||||
 | 
			
		||||
        # Convert between non base currencies
 | 
			
		||||
        convert_money(Money(100, 'CAD'), 'NZD')
 | 
			
		||||
 | 
			
		||||
        # Convert to a symbol which is not covered
 | 
			
		||||
        with self.assertRaises(MissingRate):
 | 
			
		||||
            convert_money(Money(100, 'GBP'), 'ZWL')
 | 
			
		||||
 
 | 
			
		||||
@@ -39,9 +39,9 @@ from rest_framework.documentation import include_docs_urls
 | 
			
		||||
 | 
			
		||||
from .views import IndexView, SearchView, DatabaseStatsView
 | 
			
		||||
from .views import SettingsView, EditUserView, SetPasswordView
 | 
			
		||||
from .views import CurrencySettingsView, CurrencyRefreshView
 | 
			
		||||
from .views import AppearanceSelectView, SettingCategorySelectView
 | 
			
		||||
from .views import DynamicJsView
 | 
			
		||||
from .views import CurrencySettingsView
 | 
			
		||||
 | 
			
		||||
from common.views import SettingEdit
 | 
			
		||||
 | 
			
		||||
@@ -83,15 +83,16 @@ settings_urls = [
 | 
			
		||||
    url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
 | 
			
		||||
    url(r'^i18n/?', include('django.conf.urls.i18n')),
 | 
			
		||||
 | 
			
		||||
    url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
 | 
			
		||||
    url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
 | 
			
		||||
    url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
 | 
			
		||||
    url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
 | 
			
		||||
    url(r'^stock/?', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
 | 
			
		||||
    url(r'^build/?', SettingsView.as_view(template_name='InvenTree/settings/build.html'), name='settings-build'),
 | 
			
		||||
    url(r'^purchase-order/?', SettingsView.as_view(template_name='InvenTree/settings/po.html'), name='settings-po'),
 | 
			
		||||
    url(r'^sales-order/?', SettingsView.as_view(template_name='InvenTree/settings/so.html'), name='settings-so'),
 | 
			
		||||
    url(r'^currencies/?', CurrencySettingsView.as_view(), name='settings-currencies'),
 | 
			
		||||
    url(r'^global/', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
 | 
			
		||||
    url(r'^report/', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
 | 
			
		||||
    url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'),
 | 
			
		||||
    url(r'^part/', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
 | 
			
		||||
    url(r'^stock/', SettingsView.as_view(template_name='InvenTree/settings/stock.html'), name='settings-stock'),
 | 
			
		||||
    url(r'^build/', SettingsView.as_view(template_name='InvenTree/settings/build.html'), name='settings-build'),
 | 
			
		||||
    url(r'^purchase-order/', SettingsView.as_view(template_name='InvenTree/settings/po.html'), name='settings-po'),
 | 
			
		||||
    url(r'^sales-order/', SettingsView.as_view(template_name='InvenTree/settings/so.html'), name='settings-so'),
 | 
			
		||||
    url(r'^currencies/', CurrencySettingsView.as_view(), name='settings-currencies'),
 | 
			
		||||
    url(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),
 | 
			
		||||
 | 
			
		||||
    url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'),
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -12,24 +12,26 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.template.loader import render_to_string
 | 
			
		||||
from django.http import JsonResponse, HttpResponseRedirect
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.mixins import PermissionRequiredMixin
 | 
			
		||||
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.generic import ListView, DetailView, CreateView, FormView, DeleteView, UpdateView
 | 
			
		||||
from django.views.generic.base import TemplateView
 | 
			
		||||
from django.views.generic.base import RedirectView, TemplateView
 | 
			
		||||
 | 
			
		||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
 | 
			
		||||
 | 
			
		||||
from part.models import Part, PartCategory
 | 
			
		||||
from stock.models import StockLocation, StockItem
 | 
			
		||||
from common.models import InvenTreeSetting, ColorTheme
 | 
			
		||||
from users.models import check_user_role, RuleSet
 | 
			
		||||
from InvenTree.helpers import clean_decimal
 | 
			
		||||
 | 
			
		||||
import InvenTree.tasks
 | 
			
		||||
 | 
			
		||||
from .forms import DeleteForm, EditUserForm, SetPasswordForm
 | 
			
		||||
from .forms import ColorThemeSelectForm, SettingCategorySelectForm
 | 
			
		||||
from .forms import SettingExchangeRatesForm
 | 
			
		||||
from .helpers import str2bool
 | 
			
		||||
from .exchange import get_exchange_rate_backend
 | 
			
		||||
 | 
			
		||||
from rest_framework import views
 | 
			
		||||
 | 
			
		||||
@@ -772,6 +774,51 @@ class SettingsView(TemplateView):
 | 
			
		||||
        return ctx
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyRefreshView(RedirectView):
 | 
			
		||||
    """
 | 
			
		||||
    POST endpoint to refresh / update exchange rates
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    url = reverse_lazy("settings-currencies")
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        On a POST request we will attempt to refresh the exchange rates
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        # Will block for a little bit
 | 
			
		||||
        InvenTree.tasks.update_exchange_rates()
 | 
			
		||||
 | 
			
		||||
        return self.get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencySettingsView(TemplateView):
 | 
			
		||||
    """
 | 
			
		||||
    View for configuring currency settings
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    template_name = "InvenTree/settings/currencies.html"
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
 | 
			
		||||
        ctx = super().get_context_data(**kwargs).copy()
 | 
			
		||||
 | 
			
		||||
        ctx['settings'] = InvenTreeSetting.objects.all().order_by('key')
 | 
			
		||||
        ctx["base_currency"] = settings.BASE_CURRENCY
 | 
			
		||||
        ctx["currencies"] = settings.CURRENCIES
 | 
			
		||||
 | 
			
		||||
        ctx["rates"] = Rate.objects.filter(backend="InvenTreeExchange")
 | 
			
		||||
 | 
			
		||||
        # When were the rates last updated?
 | 
			
		||||
        try:
 | 
			
		||||
            backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
 | 
			
		||||
            ctx["rates_updated"] = backend.last_update
 | 
			
		||||
        except:
 | 
			
		||||
            ctx["rates_updated"] = None
 | 
			
		||||
 | 
			
		||||
        return ctx
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AppearanceSelectView(FormView):
 | 
			
		||||
    """ View for selecting a color theme """
 | 
			
		||||
 | 
			
		||||
@@ -911,89 +958,3 @@ class DatabaseStatsView(AjaxView):
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        return ctx
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencySettingsView(FormView):
 | 
			
		||||
 | 
			
		||||
    form_class = SettingExchangeRatesForm
 | 
			
		||||
    template_name = 'InvenTree/settings/currencies.html'
 | 
			
		||||
    success_url = reverse_lazy('settings-currencies')
 | 
			
		||||
 | 
			
		||||
    exchange_rate_backend = None
 | 
			
		||||
 | 
			
		||||
    def get_exchange_rate_backend(self):
 | 
			
		||||
 | 
			
		||||
        if not self.exchange_rate_backend:
 | 
			
		||||
            self.exchange_rate_backend = get_exchange_rate_backend()
 | 
			
		||||
        
 | 
			
		||||
        return self.exchange_rate_backend
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
 | 
			
		||||
        context = super().get_context_data(**kwargs)
 | 
			
		||||
 | 
			
		||||
        # Set default API result
 | 
			
		||||
        if 'api_rates_success' not in context:
 | 
			
		||||
            context['default_currency'] = True
 | 
			
		||||
        else:
 | 
			
		||||
            # Update form
 | 
			
		||||
            context['form'] = self.get_form()
 | 
			
		||||
 | 
			
		||||
        # Get exchange rate backend
 | 
			
		||||
        exchange_rate_backend = self.get_exchange_rate_backend()
 | 
			
		||||
 | 
			
		||||
        context['default_currency'] = exchange_rate_backend.base_currency
 | 
			
		||||
 | 
			
		||||
        context['custom_rates'] = exchange_rate_backend.custom_rates
 | 
			
		||||
 | 
			
		||||
        context['exchange_backend'] = exchange_rate_backend.name
 | 
			
		||||
 | 
			
		||||
        return context
 | 
			
		||||
 | 
			
		||||
    def get_form(self):
 | 
			
		||||
 | 
			
		||||
        form = super().get_form()
 | 
			
		||||
 | 
			
		||||
        # Get exchange rate backend
 | 
			
		||||
        exchange_rate_backend = self.get_exchange_rate_backend()
 | 
			
		||||
 | 
			
		||||
        # Get stored exchange rates
 | 
			
		||||
        stored_rates = exchange_rate_backend.get_stored_rates()
 | 
			
		||||
            
 | 
			
		||||
        for field in form.fields:
 | 
			
		||||
            if not exchange_rate_backend.custom_rates:
 | 
			
		||||
                # Disable all the fields
 | 
			
		||||
                form.fields[field].disabled = True
 | 
			
		||||
            form.fields[field].initial = clean_decimal(stored_rates.get(field, 0))
 | 
			
		||||
 | 
			
		||||
        return form
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
 | 
			
		||||
        form = self.get_form()
 | 
			
		||||
 | 
			
		||||
        # Get exchange rate backend
 | 
			
		||||
        exchange_rate_backend = self.get_exchange_rate_backend()
 | 
			
		||||
 | 
			
		||||
        if not exchange_rate_backend.custom_rates:
 | 
			
		||||
            # Refresh rate from Fixer.IO API
 | 
			
		||||
            exchange_rate_backend.update_rates(base_currency=exchange_rate_backend.base_currency)
 | 
			
		||||
            # Check if rates have been updated
 | 
			
		||||
            if not exchange_rate_backend.get_stored_rates():
 | 
			
		||||
                # Update context
 | 
			
		||||
                context = {'api_rates_success': False}
 | 
			
		||||
                # Return view with updated context
 | 
			
		||||
                return self.render_to_response(self.get_context_data(form=form, **context))
 | 
			
		||||
        else:
 | 
			
		||||
            # Update rates from form
 | 
			
		||||
            manual_rates = {}
 | 
			
		||||
 | 
			
		||||
            if form.is_valid():
 | 
			
		||||
                for field, value in form.cleaned_data.items():
 | 
			
		||||
                    manual_rates[field] = clean_decimal(value)
 | 
			
		||||
 | 
			
		||||
                exchange_rate_backend.update_rates(base_currency=exchange_rate_backend.base_currency, **{'rates': manual_rates})
 | 
			
		||||
            else:
 | 
			
		||||
                return self.form_invalid(form)
 | 
			
		||||
 | 
			
		||||
        return self.form_valid(form)
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,8 @@ from djmoney.forms.fields import MoneyField
 | 
			
		||||
from InvenTree.forms import HelperForm
 | 
			
		||||
from InvenTree.helpers import clean_decimal
 | 
			
		||||
 | 
			
		||||
from common.settings import currency_code_default
 | 
			
		||||
 | 
			
		||||
from .files import FileManager
 | 
			
		||||
from .models import InvenTreeSetting
 | 
			
		||||
 | 
			
		||||
@@ -182,7 +184,7 @@ class MatchItem(forms.Form):
 | 
			
		||||
                        if 'price' in col_guess.lower():
 | 
			
		||||
                            self.fields[field_name] = MoneyField(
 | 
			
		||||
                                label=_(col_guess),
 | 
			
		||||
                                default_currency=InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY'),
 | 
			
		||||
                                default_currency=currency_code_default(),
 | 
			
		||||
                                decimal_places=5,
 | 
			
		||||
                                max_digits=19,
 | 
			
		||||
                                required=False,
 | 
			
		||||
 
 | 
			
		||||
@@ -14,11 +14,12 @@ from django.db import models, transaction
 | 
			
		||||
from django.db.utils import IntegrityError, OperationalError
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
import djmoney.settings
 | 
			
		||||
from djmoney.models.fields import MoneyField
 | 
			
		||||
from djmoney.contrib.exchange.models import convert_money
 | 
			
		||||
from djmoney.contrib.exchange.exceptions import MissingRate
 | 
			
		||||
 | 
			
		||||
from common.settings import currency_code_default
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
from django.core.validators import MinValueValidator, URLValidator
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
@@ -80,20 +81,6 @@ class InvenTreeSetting(models.Model):
 | 
			
		||||
            'default': '',
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'INVENTREE_DEFAULT_CURRENCY': {
 | 
			
		||||
            'name': _('Default Currency'),
 | 
			
		||||
            'description': _('Default currency'),
 | 
			
		||||
            'default': 'USD',
 | 
			
		||||
            'choices': djmoney.settings.CURRENCY_CHOICES,
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'CUSTOM_EXCHANGE_RATES': {
 | 
			
		||||
            'name': _('Custom Exchange Rates'),
 | 
			
		||||
            'description': _('Enable custom exchange rates'),
 | 
			
		||||
            'validator': bool,
 | 
			
		||||
            'default': False,
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        'INVENTREE_DOWNLOAD_FROM_URL': {
 | 
			
		||||
            'name': _('Download from URL'),
 | 
			
		||||
            'description': _('Allow download of remote images and files from external URL'),
 | 
			
		||||
@@ -766,7 +753,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
 | 
			
		||||
 | 
			
		||||
    if currency is None:
 | 
			
		||||
        # Default currency selection
 | 
			
		||||
        currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
 | 
			
		||||
        currency = currency_code_default()
 | 
			
		||||
 | 
			
		||||
    pb_min = None
 | 
			
		||||
    for pb in instance.price_breaks.all():
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,8 @@ from __future__ import unicode_literals
 | 
			
		||||
 | 
			
		||||
from moneyed import CURRENCIES
 | 
			
		||||
 | 
			
		||||
from common.models import InvenTreeSetting
 | 
			
		||||
import common.models
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def currency_code_default():
 | 
			
		||||
@@ -15,7 +16,7 @@ def currency_code_default():
 | 
			
		||||
    Returns the default currency code (or USD if not specified)
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    code = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
 | 
			
		||||
    code = settings.BASE_CURRENCY
 | 
			
		||||
 | 
			
		||||
    if code not in CURRENCIES:
 | 
			
		||||
        code = 'USD'
 | 
			
		||||
@@ -28,4 +29,4 @@ def stock_expiry_enabled():
 | 
			
		||||
    Returns True if the stock expiry feature is enabled
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')
 | 
			
		||||
    return common.models.InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')
 | 
			
		||||
 
 | 
			
		||||
@@ -98,20 +98,15 @@ class SettingsViewTest(TestCase):
 | 
			
		||||
        Tests for a setting which has choices
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        setting = InvenTreeSetting.get_setting_object('INVENTREE_DEFAULT_CURRENCY')
 | 
			
		||||
        setting = InvenTreeSetting.get_setting_object('PURCHASEORDER_REFERENCE_PREFIX')
 | 
			
		||||
 | 
			
		||||
        # Default value!
 | 
			
		||||
        self.assertEqual(setting.value, 'USD')
 | 
			
		||||
        self.assertEqual(setting.value, 'PO')
 | 
			
		||||
 | 
			
		||||
        url = self.get_url(setting.pk)
 | 
			
		||||
 | 
			
		||||
        # Try posting an invalid currency option
 | 
			
		||||
        data, errors = self.post(url, {'value': 'XPQaaa'}, valid=False)
 | 
			
		||||
 | 
			
		||||
        self.assertIsNotNone(errors.get('value'), None)
 | 
			
		||||
 | 
			
		||||
        # Try posting a valid currency option
 | 
			
		||||
        data, errors = self.post(url, {'value': 'AUD'}, valid=True)
 | 
			
		||||
        data, errors = self.post(url, {'value': 'Purchase Order'}, valid=True)
 | 
			
		||||
 | 
			
		||||
    def test_binary_values(self):
 | 
			
		||||
        """
 | 
			
		||||
 
 | 
			
		||||
@@ -11,9 +11,6 @@ from .models import Company, Contact, ManufacturerPart, SupplierPart
 | 
			
		||||
from .models import rename_company_image
 | 
			
		||||
from part.models import Part
 | 
			
		||||
 | 
			
		||||
from InvenTree.exchange import InvenTreeManualExchangeBackend
 | 
			
		||||
from djmoney.contrib.exchange.models import Rate
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CompanySimpleTest(TestCase):
 | 
			
		||||
 | 
			
		||||
@@ -40,16 +37,6 @@ class CompanySimpleTest(TestCase):
 | 
			
		||||
        self.acme0002 = SupplierPart.objects.get(SKU='ACME0002')
 | 
			
		||||
        self.zerglphs = SupplierPart.objects.get(SKU='ZERGLPHS')
 | 
			
		||||
        self.zergm312 = SupplierPart.objects.get(SKU='ZERGM312')
 | 
			
		||||
        
 | 
			
		||||
        # Exchange rate backend
 | 
			
		||||
        backend = InvenTreeManualExchangeBackend()
 | 
			
		||||
        backend.update_rates(base_currency=backend.base_currency)
 | 
			
		||||
 | 
			
		||||
        Rate.objects.create(
 | 
			
		||||
            currency='AUD',
 | 
			
		||||
            value='1.35',
 | 
			
		||||
            backend_id=backend.name,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_company_model(self):
 | 
			
		||||
        c = Company.objects.get(name='ABC Co.')
 | 
			
		||||
 
 | 
			
		||||
@@ -52,6 +52,9 @@ language: en-us
 | 
			
		||||
# Use the environment variable INVENTREE_TIMEZONE
 | 
			
		||||
timezone: UTC
 | 
			
		||||
 | 
			
		||||
# Base currency code
 | 
			
		||||
base_currency: USD
 | 
			
		||||
 | 
			
		||||
# List of currencies supported by default.
 | 
			
		||||
# Add other currencies here to allow use in InvenTree
 | 
			
		||||
currencies:
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ from __future__ import unicode_literals
 | 
			
		||||
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from django.http import JsonResponse
 | 
			
		||||
from django.db.models import Q, F, Count
 | 
			
		||||
from django.db.models import Q, F, Count, Min, Max, Avg
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from rest_framework import status
 | 
			
		||||
@@ -15,6 +15,10 @@ from rest_framework.response import Response
 | 
			
		||||
from rest_framework import filters, serializers
 | 
			
		||||
from rest_framework import generics
 | 
			
		||||
 | 
			
		||||
from djmoney.money import Money
 | 
			
		||||
from djmoney.contrib.exchange.models import convert_money
 | 
			
		||||
from djmoney.contrib.exchange.exceptions import MissingRate
 | 
			
		||||
 | 
			
		||||
from django.conf.urls import url, include
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
@@ -24,6 +28,7 @@ from .models import PartAttachment, PartTestTemplate
 | 
			
		||||
from .models import PartSellPriceBreak
 | 
			
		||||
from .models import PartCategoryParameterTemplate
 | 
			
		||||
 | 
			
		||||
from common.models import InvenTreeSetting
 | 
			
		||||
from build.models import Build
 | 
			
		||||
 | 
			
		||||
from . import serializers as part_serializers
 | 
			
		||||
@@ -877,6 +882,60 @@ class BomList(generics.ListCreateAPIView):
 | 
			
		||||
            else:
 | 
			
		||||
                queryset = queryset.exclude(pk__in=pks)
 | 
			
		||||
 | 
			
		||||
        # Annotate with purchase prices
 | 
			
		||||
        queryset = queryset.annotate(
 | 
			
		||||
            purchase_price_min=Min('sub_part__stock_items__purchase_price'),
 | 
			
		||||
            purchase_price_max=Max('sub_part__stock_items__purchase_price'),
 | 
			
		||||
            purchase_price_avg=Avg('sub_part__stock_items__purchase_price'),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Get values for currencies
 | 
			
		||||
        currencies = queryset.annotate(
 | 
			
		||||
            purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'),
 | 
			
		||||
        ).values('pk', 'sub_part', 'purchase_price_currency')
 | 
			
		||||
 | 
			
		||||
        def convert_price(price, currency, decimal_places=4):
 | 
			
		||||
            """ Convert price field, returns Money field """
 | 
			
		||||
 | 
			
		||||
            price_adjusted = None
 | 
			
		||||
 | 
			
		||||
            # Get default currency from settings
 | 
			
		||||
            default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
 | 
			
		||||
            
 | 
			
		||||
            if price:
 | 
			
		||||
                if currency and default_currency:
 | 
			
		||||
                    try:
 | 
			
		||||
                        # Get adjusted price
 | 
			
		||||
                        price_adjusted = convert_money(Money(price, currency), default_currency)
 | 
			
		||||
                    except MissingRate:
 | 
			
		||||
                        # No conversion rate set
 | 
			
		||||
                        price_adjusted = Money(price, currency)
 | 
			
		||||
                else:
 | 
			
		||||
                    # Currency exists
 | 
			
		||||
                    if currency:
 | 
			
		||||
                        price_adjusted = Money(price, currency)
 | 
			
		||||
                    # Default currency exists
 | 
			
		||||
                    if default_currency:
 | 
			
		||||
                        price_adjusted = Money(price, default_currency)
 | 
			
		||||
 | 
			
		||||
            if price_adjusted and decimal_places:
 | 
			
		||||
                price_adjusted.decimal_places = decimal_places
 | 
			
		||||
 | 
			
		||||
            return price_adjusted
 | 
			
		||||
 | 
			
		||||
        # Convert prices to default currency (using backend conversion rates)
 | 
			
		||||
        for bom_item in queryset:
 | 
			
		||||
            # Find associated currency (select first found)
 | 
			
		||||
            purchase_price_currency = None
 | 
			
		||||
            for currency_item in currencies:
 | 
			
		||||
                if currency_item['pk'] == bom_item.pk and currency_item['sub_part'] == bom_item.sub_part.pk:
 | 
			
		||||
                    purchase_price_currency = currency_item['purchase_price_currency']
 | 
			
		||||
                    break
 | 
			
		||||
            # Convert prices
 | 
			
		||||
            bom_item.purchase_price_min = convert_price(bom_item.purchase_price_min, purchase_price_currency)
 | 
			
		||||
            bom_item.purchase_price_max = convert_price(bom_item.purchase_price_max, purchase_price_currency)
 | 
			
		||||
            bom_item.purchase_price_avg = convert_price(bom_item.purchase_price_avg, purchase_price_currency)
 | 
			
		||||
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
 
 | 
			
		||||
@@ -1861,6 +1861,59 @@ class Part(MPTTModel):
 | 
			
		||||
 | 
			
		||||
        return self.get_descendants(include_self=False)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def can_convert(self):
 | 
			
		||||
        """
 | 
			
		||||
        Check if this Part can be "converted" to a different variant:
 | 
			
		||||
 | 
			
		||||
        It can be converted if:
 | 
			
		||||
 | 
			
		||||
        a) It has non-virtual variant parts underneath it
 | 
			
		||||
        b) It has non-virtual template parts above it
 | 
			
		||||
        c) It has non-virtual sibling variants
 | 
			
		||||
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        return self.get_conversion_options().count() > 0
 | 
			
		||||
 | 
			
		||||
    def get_conversion_options(self):
 | 
			
		||||
        """
 | 
			
		||||
        Return options for converting this part to a "variant" within the same tree
 | 
			
		||||
 | 
			
		||||
        a) Variants underneath this one
 | 
			
		||||
        b) Immediate parent
 | 
			
		||||
        c) Siblings
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        parts = []
 | 
			
		||||
 | 
			
		||||
        # Child parts
 | 
			
		||||
        children = self.get_descendants(include_self=False)
 | 
			
		||||
 | 
			
		||||
        for child in children:
 | 
			
		||||
            parts.append(child)
 | 
			
		||||
 | 
			
		||||
        # Immediate parent
 | 
			
		||||
        if self.variant_of:
 | 
			
		||||
            parts.append(self.variant_of)
 | 
			
		||||
 | 
			
		||||
        siblings = self.get_siblings(include_self=False)
 | 
			
		||||
 | 
			
		||||
        for sib in siblings:
 | 
			
		||||
            parts.append(sib)
 | 
			
		||||
 | 
			
		||||
        filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts])
 | 
			
		||||
 | 
			
		||||
        # Ensure this part is not in the queryset, somehow
 | 
			
		||||
        filtered_parts = filtered_parts.exclude(pk=self.pk)
 | 
			
		||||
 | 
			
		||||
        filtered_parts = filtered_parts.filter(
 | 
			
		||||
            active=True,
 | 
			
		||||
            virtual=False,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        return filtered_parts
 | 
			
		||||
 | 
			
		||||
    def get_related_parts(self):
 | 
			
		||||
        """ Return list of tuples for all related parts:
 | 
			
		||||
            - first value is PartRelated object
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
 | 
			
		||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
from sql_util.utils import SubqueryCount, SubquerySum
 | 
			
		||||
from djmoney.contrib.django_rest_framework import MoneyField
 | 
			
		||||
from stock.models import StockItem
 | 
			
		||||
 | 
			
		||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
 | 
			
		||||
@@ -367,6 +368,14 @@ class BomItemSerializer(InvenTreeModelSerializer):
 | 
			
		||||
 | 
			
		||||
    validated = serializers.BooleanField(read_only=True, source='is_line_valid')
 | 
			
		||||
 | 
			
		||||
    purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True)
 | 
			
		||||
 | 
			
		||||
    purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True)
 | 
			
		||||
    
 | 
			
		||||
    purchase_price_avg = serializers.SerializerMethodField()
 | 
			
		||||
    
 | 
			
		||||
    purchase_price_range = serializers.SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        # part_detail and sub_part_detail serializers are only included if requested.
 | 
			
		||||
        # This saves a bunch of database requests
 | 
			
		||||
@@ -394,6 +403,53 @@ class BomItemSerializer(InvenTreeModelSerializer):
 | 
			
		||||
        queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    def get_purchase_price_range(self, obj):
 | 
			
		||||
        """ Return purchase price range """
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            purchase_price_min = obj.purchase_price_min
 | 
			
		||||
        except AttributeError:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            purchase_price_max = obj.purchase_price_max
 | 
			
		||||
        except AttributeError:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        if purchase_price_min and not purchase_price_max:
 | 
			
		||||
            # Get price range
 | 
			
		||||
            purchase_price_range = str(purchase_price_max)
 | 
			
		||||
        elif not purchase_price_min and purchase_price_max:
 | 
			
		||||
            # Get price range
 | 
			
		||||
            purchase_price_range = str(purchase_price_max)
 | 
			
		||||
        elif purchase_price_min and purchase_price_max:
 | 
			
		||||
            # Get price range
 | 
			
		||||
            if purchase_price_min >= purchase_price_max:
 | 
			
		||||
                # If min > max: use min only
 | 
			
		||||
                purchase_price_range = str(purchase_price_min)
 | 
			
		||||
            else:
 | 
			
		||||
                purchase_price_range = str(purchase_price_min) + " - " + str(purchase_price_max)
 | 
			
		||||
        else:
 | 
			
		||||
            purchase_price_range = '-'
 | 
			
		||||
 | 
			
		||||
        return purchase_price_range
 | 
			
		||||
 | 
			
		||||
    def get_purchase_price_avg(self, obj):
 | 
			
		||||
        """ Return purchase price average """
 | 
			
		||||
        
 | 
			
		||||
        try:
 | 
			
		||||
            purchase_price_avg = obj.purchase_price_avg
 | 
			
		||||
        except AttributeError:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        if purchase_price_avg:
 | 
			
		||||
            # Get string representation of price average
 | 
			
		||||
            purchase_price_avg = str(purchase_price_avg)
 | 
			
		||||
        else:
 | 
			
		||||
            purchase_price_avg = '-'
 | 
			
		||||
 | 
			
		||||
        return purchase_price_avg
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = BomItem
 | 
			
		||||
        fields = [
 | 
			
		||||
@@ -410,6 +466,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
 | 
			
		||||
            'sub_part_detail',
 | 
			
		||||
            # 'price_range',
 | 
			
		||||
            'validated',
 | 
			
		||||
            'purchase_price_min',
 | 
			
		||||
            'purchase_price_max',
 | 
			
		||||
            'purchase_price_avg',
 | 
			
		||||
            'purchase_price_range',
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -102,6 +102,11 @@
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <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 %}
 | 
			
		||||
            <div class='alert alert-info alert-block'>
 | 
			
		||||
                {% object_link 'part-variants' part.variant_of.id part.variant_of.full_name as link %}
 | 
			
		||||
 
 | 
			
		||||
@@ -2675,7 +2675,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
 | 
			
		||||
 | 
			
		||||
        initials['part'] = self.get_part()
 | 
			
		||||
 | 
			
		||||
        default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
 | 
			
		||||
        default_currency = settings.BASE_CURRENCY
 | 
			
		||||
        currency = CURRENCIES.get(default_currency, None)
 | 
			
		||||
 | 
			
		||||
        if currency is not None:
 | 
			
		||||
 
 | 
			
		||||
@@ -139,7 +139,7 @@
 | 
			
		||||
        <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>
 | 
			
		||||
            <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>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                {% 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() {
 | 
			
		||||
    launchModalForm("{% url 'stock-item-convert' item.id %}",
 | 
			
		||||
        {
 | 
			
		||||
 
 | 
			
		||||
@@ -1373,7 +1373,7 @@ class StockItemConvert(AjaxUpdateView):
 | 
			
		||||
        form = super().get_form()
 | 
			
		||||
        item = self.get_object()
 | 
			
		||||
 | 
			
		||||
        form.fields['part'].queryset = item.part.get_all_variants()
 | 
			
		||||
        form.fields['part'].queryset = item.part.get_conversion_options()
 | 
			
		||||
 | 
			
		||||
        return form
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,40 +13,43 @@
 | 
			
		||||
{% block settings %}
 | 
			
		||||
 | 
			
		||||
<table class='table table-striped table-condensed'>
 | 
			
		||||
    {% include "InvenTree/settings/header.html" %}
 | 
			
		||||
    <tbody>
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}
 | 
			
		||||
        {% include "InvenTree/settings/setting.html" with key="CUSTOM_EXCHANGE_RATES" icon="fa-edit" %}
 | 
			
		||||
        <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>
 | 
			
		||||
 | 
			
		||||
<div class='row'>
 | 
			
		||||
    <div class='col-sm-6'>
 | 
			
		||||
        <h4>{% blocktrans with cur=default_currency %}Exchange Rates - Convert to {{cur}}{% endblocktrans %}</h4>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<form action="{% url 'settings-currencies' %}" method="post">
 | 
			
		||||
    <div id='exchange_rate_form'>
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
    {% load crispy_forms_tags %}
 | 
			
		||||
    {% crispy form %}
 | 
			
		||||
    {% if custom_rates is False %}
 | 
			
		||||
        <button type="submit" class='btn btn-primary'>{% trans "Refresh Exchange Rates" %}</button>
 | 
			
		||||
    {% else %}
 | 
			
		||||
        <button type="submit" class='btn btn-primary'>{% trans "Update Exchange Rates" %}</button>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
</form>
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block js_ready %}
 | 
			
		||||
{{ block.super }}
 | 
			
		||||
 | 
			
		||||
{% if api_rates_success is False %}
 | 
			
		||||
    var alert_msg = {% blocktrans %}"Failed to refresh exchange rates" {% endblocktrans %};
 | 
			
		||||
    showAlertOrCache("alert-danger", alert_msg, null, 5000);
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -15,6 +15,9 @@
 | 
			
		||||
    <li {% if tab == 'global' %} class='active' {% endif %}>
 | 
			
		||||
        <a href='{% url "settings-global" %}'><span class='fas fa-globe'></span> {% trans "Global" %}</a>
 | 
			
		||||
    </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 %}>
 | 
			
		||||
        <a href='{% url "settings-report" %}'><span class='fas fa-file-pdf'></span> {% trans "Report" %}</a>
 | 
			
		||||
    </li>
 | 
			
		||||
@@ -36,8 +39,5 @@
 | 
			
		||||
    <li {% if tab == 'so' %} class='active'{% endif %}>
 | 
			
		||||
        <a href="{% url 'settings-so' %}"><span class='fas fa-truck'></span> {% trans "Sales Orders" %}</a>
 | 
			
		||||
    </li>
 | 
			
		||||
    <li {% if tab == 'currencies' %} class='active'{% endif %}>
 | 
			
		||||
        <a href="{% url 'settings-currencies' %}"><span class='fas fa-dollar-sign'></span> {% trans "Currencies" %}</a>
 | 
			
		||||
    </li>
 | 
			
		||||
</ul>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,7 @@ django-stdimage==5.1.1          # Advanced ImageField management
 | 
			
		||||
django-weasyprint==1.0.1        # HTML PDF export
 | 
			
		||||
django-debug-toolbar==2.2       # Debug / profiling toolbar
 | 
			
		||||
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
 | 
			
		||||
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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user