Currency setting (#7390)

* Add new global setting for currency options

- Moving away from external configuration
- Refactor currency support code into new file

* Refactoring

- Move functions into currency.py

* Limit choices for default currency

* Improve validation

* Adds data migration for existing currency selection

* Docs updates

* Remove currency config from external settings

* bump api version

* Add debug message

* Add unit tests

* Fix after_change_currency func

* Fix after_change_currency func

* Revert change to after_chance_currency

* Revert other change
This commit is contained in:
Oliver 2024-06-03 12:53:30 +10:00 committed by GitHub
parent 7108bc48bd
commit e83feb9414
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 417 additions and 278 deletions

View File

@ -6,13 +6,13 @@ title: Currency Support
InvenTree provides support for multiple currencies, allowing pricing information to be stored with base currency rates.
### Configuration
### Supported Currencies
To specify which currencies are supported, refer to the [currency configuration](../start/config.md#supported-currencies) section
InvenTree uses the [django-money](https://github.com/django-money/django-money) library, which in turn uses the [py-moneyed library](https://py-moneyed.readthedocs.io/en/latest/index.html). `py-moneyed` supports any currency which is defined in the [ISO 3166 standard](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) standard.
### Currency Conversion
Currency conversion is provided via the [django-money](https://github.com/django-money/django-money) library. Pricing data can be converted seamlessly between the available currencies.
Currency conversion is provided via the `django-money` library. Pricing data can be converted seamlessly between the available currencies.
### Currency Rate Updates
@ -26,6 +26,11 @@ If a different currency exchange backend is needed, or a custom implementation i
In the [settings screen](./global.md), under the *Pricing* section, the following currency settings are available:
{% with id="currency-settings", url="settings/currency.png", description="Currency Exchange Settings" %}
{% include 'img.html' %}
{% endwith %}
| Setting | Description | Default Value |
| --- | --- |
| Default Currency | The selected *default* currency for the system. | USD |
| Supported Currencies | The list of supported currencies for the system. | AUD, CAD, CNY, EUR, GBP, JPY, NZD, USD |
#### Supported Currencies
While InvenTree can support any of the currencies defined in the ISO 3166 standard, the list of supported currencies can be limited to only those which are relevant to the user. The supported currencies are used to populate the currency selection dropdowns throughout the InvenTree interface.

View File

@ -256,19 +256,6 @@ The "sender" email address is the address from which InvenTree emails are sent (
!!! info "Fallback"
If `INVENTREE_EMAIL_SENDER` is not provided, the system will fall back to `INVENTREE_EMAIL_USERNAME` (if the username is a valid email address)
## Supported Currencies
The currencies supported by InvenTree must be specified in the [configuration file](#configuration-file).
A list of currency codes (e.g. *AUD*, *CAD*, *JPY*, *USD*) can be specified using the `currencies` variable (or using the `INVENTREE_CURRENCIES` environment variable).
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_CURRENCIES | currencies | List of supported currencies| `AUD`, `CAD`, `CNY`, `EUR`, `GBP`, `JPY`, `NZD`, `USD` |
!!! tip "More Info"
Read the [currencies documentation](../settings/currency.md) for more information on currency support in InvenTree
## File Storage Locations
InvenTree requires some external directories for storing files:

View File

@ -1,12 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 202
INVENTREE_API_VERSION = 203
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v203 - 2024-06-03 : https://github.com/inventree/InvenTree/pull/7390
- Adjust default currency codes
v202 - 2024-05-27 : https://github.com/inventree/InvenTree/pull/7343
- Adjust "required" attribute of Part.category field to be optional

View File

@ -185,7 +185,7 @@ class InvenTreeConfig(AppConfig):
try:
from djmoney.contrib.exchange.models import ExchangeBackend
from common.settings import currency_code_default
from common.currency import currency_code_default
from InvenTree.tasks import update_exchange_rates
except AppRegistryNotReady: # pragma: no cover
pass

View File

@ -7,7 +7,7 @@ from django.db.transaction import atomic
from djmoney.contrib.exchange.backends.base import SimpleExchangeBackend
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from common.settings import currency_code_default, currency_codes
from common.currency import currency_code_default, currency_codes
logger = logging.getLogger('inventree')

View File

@ -59,7 +59,7 @@ class InvenTreeURLField(models.URLField):
def money_kwargs(**kwargs):
"""Returns the database settings for MoneyFields."""
from common.settings import currency_code_default, currency_code_mappings
from common.currency import currency_code_default, currency_code_mappings
# Default values (if not specified)
if 'max_digits' not in kwargs:

View File

@ -28,7 +28,7 @@ from djmoney.money import Money
from PIL import Image
import InvenTree.version
from common.settings import currency_code_default
from common.currency import currency_code_default
from .settings import MEDIA_URL, STATIC_URL

View File

@ -23,7 +23,7 @@ from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer
import common.models as common_models
from common.settings import currency_code_default, currency_code_mappings
from common.currency import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField

View File

@ -20,7 +20,6 @@ from django.core.validators import URLValidator
from django.http import Http404
from django.utils.translation import gettext_lazy as _
import moneyed
import pytz
from dotenv import load_dotenv
@ -904,28 +903,9 @@ if get_boolean_setting('TEST_TRANSLATIONS', default_value=False): # pragma: no
LANG_INFO = dict(django.conf.locale.LANG_INFO, **EXTRA_LANG_INFO)
django.conf.locale.LANG_INFO = LANG_INFO
# Currencies available for use
CURRENCIES = get_setting(
'INVENTREE_CURRENCIES',
'currencies',
['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD'],
typecast=list,
)
# Ensure that at least one currency value is available
if len(CURRENCIES) == 0: # pragma: no cover
logger.warning('No currencies selected: Defaulting to USD')
CURRENCIES = ['USD']
# Maximum number of decimal places for currency rendering
CURRENCY_DECIMAL_PLACES = 6
# Check that each provided currency is supported
for currency in CURRENCIES:
if currency not in moneyed.CURRENCIES: # pragma: no cover
logger.error("Currency code '%s' is not supported", currency)
sys.exit(1)
# Custom currency exchange backend
EXCHANGE_BACKEND = 'InvenTree.exchange.InvenTreeExchange'

View File

@ -571,8 +571,8 @@ def update_exchange_rates(force: bool = False):
try:
from djmoney.contrib.exchange.models import Rate
from common.currency import currency_code_default, currency_codes
from common.models import InvenTreeSetting
from common.settings import currency_code_default, currency_codes
from InvenTree.exchange import InvenTreeExchange
except AppRegistryNotReady: # pragma: no cover
# Apps not yet loaded!

View File

@ -16,7 +16,7 @@ import common.models
import InvenTree.helpers
import InvenTree.helpers_model
import plugin.models
from common.settings import currency_code_default
from common.currency import currency_code_default
from InvenTree import settings, version
from plugin import registry
from plugin.plugin import InvenTreePlugin

View File

@ -29,8 +29,8 @@ import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
import InvenTree.tasks
from common.currency import currency_codes
from common.models import CustomUnit, InvenTreeSetting
from common.settings import currency_codes
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase

View File

@ -25,8 +25,8 @@ from allauth.socialaccount.views import ConnectionsView
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
import common.currency
import common.models as common_models
import common.settings as common_settings
from part.models import PartCategory
from users.models import RuleSet, check_user_role
@ -506,8 +506,8 @@ class SettingsView(TemplateView):
ctx['settings'] = common_models.InvenTreeSetting.objects.all().order_by('key')
ctx['base_currency'] = common_settings.currency_code_default()
ctx['currencies'] = common_settings.currency_codes
ctx['base_currency'] = common.currency.currency_code_default()
ctx['currencies'] = common.currency.currency_codes
ctx['rates'] = Rate.objects.filter(backend='InvenTreeExchange')

View File

@ -0,0 +1,226 @@
"""Helper functions for currency support."""
import decimal
import logging
import math
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from moneyed import CURRENCIES
import InvenTree.helpers
logger = logging.getLogger('inventree')
def currency_code_default():
"""Returns the default currency code (or USD if not specified)."""
from common.models import InvenTreeSetting
try:
cached_value = cache.get('currency_code_default', '')
except Exception:
cached_value = None
if cached_value:
return cached_value
try:
code = InvenTreeSetting.get_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''
if code not in CURRENCIES:
code = 'USD' # pragma: no cover
# Cache the value for a short amount of time
try:
cache.set('currency_code_default', code, 30)
except Exception:
pass
return code
def all_currency_codes() -> list:
"""Returns a list of all currency codes."""
return [(a, CURRENCIES[a].name) for a in CURRENCIES]
def currency_codes_default_list() -> str:
"""Return a comma-separated list of default currency codes."""
return 'AUD,CAD,CNY,EUR,GBP,JPY,NZD,USD'
def currency_codes() -> list:
"""Returns the current currency codes."""
from common.models import InvenTreeSetting
codes = InvenTreeSetting.get_setting('CURRENCY_CODES', '', create=False).strip()
if not codes:
codes = currency_codes_default_list()
codes = codes.split(',')
valid_codes = set()
for code in codes:
code = code.strip().upper()
if code in CURRENCIES:
valid_codes.add(code)
else:
logger.warning(f"Invalid currency code: '{code}'")
if len(valid_codes) == 0:
valid_codes = set(currency_codes_default_list().split(','))
return list(valid_codes)
def currency_code_mappings() -> list:
"""Returns the current currency choices."""
return [(a, CURRENCIES[a].name) for a in currency_codes()]
def after_change_currency(setting) -> None:
"""Callback function when base currency is changed.
- Update exchange rates
- Recalculate prices for all parts
"""
import InvenTree.ready
import InvenTree.tasks
if InvenTree.ready.isImportingData():
return
if not InvenTree.ready.canAppAccessDatabase():
return
from part import tasks as part_tasks
# Immediately update exchange rates
InvenTree.tasks.update_exchange_rates(force=True)
# Offload update of part prices to a background task
InvenTree.tasks.offload_task(part_tasks.check_missing_pricing, force_async=True)
def validate_currency_codes(value):
"""Validate the currency codes."""
values = value.strip().split(',')
valid_currencies = set()
for code in values:
code = code.strip().upper()
if not code:
continue
if code not in CURRENCIES:
raise ValidationError(_('Invalid currency code') + f": '{code}'")
elif code in valid_currencies:
raise ValidationError(_('Duplicate currency code') + f": '{code}'")
else:
valid_currencies.add(code)
if len(valid_currencies) == 0:
raise ValidationError(_('No valid currency codes provided'))
return list(valid_currencies)
def currency_exchange_plugins() -> list:
"""Return a list of plugin choices which can be used for currency exchange."""
try:
from plugin import registry
plugs = registry.with_mixin('currencyexchange', active=True)
except Exception:
plugs = []
return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs]
def get_price(
instance,
quantity,
moq=True,
multiples=True,
currency=None,
break_name: str = 'price_breaks',
):
"""Calculate the price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too
"""
from common.currency import currency_code_default
if hasattr(instance, break_name):
price_breaks = getattr(instance, break_name).all()
else:
price_breaks = []
# No price break information available?
if len(price_breaks) == 0:
return None
# Check if quantity is fraction and disable multiples
multiples = quantity % 1 == 0
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
if currency is None:
# Default currency selection
currency = currency_code_default()
pb_min = None
for pb in price_breaks:
# Store smallest price break
if not pb_min:
pb_min = pb
# Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity:
continue
pb_found = True
# If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity:
pb_quantity = pb.quantity
# Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
# Use smallest price break
if not pb_found and pb_min:
# Update price break information
pb_quantity = pb_min.quantity
pb_cost = pb_min.convert_to(currency)
# Trigger cost calculation using smallest price break
pb_found = True
# Convert quantity to decimal.Decimal format
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
return InvenTree.helpers.normalize(cost + instance.base_cost)
return None

View File

@ -9,7 +9,7 @@ def set_default_currency(apps, schema_editor):
# get value from settings-file
base_currency = get_setting('INVENTREE_BASE_CURRENCY', 'base_currency', 'USD')
from common.settings import currency_codes
from common.currency import currency_codes
# check if value is valid
if base_currency not in currency_codes():

View File

@ -0,0 +1,73 @@
# Generated by Django 4.2.12 on 2024-06-02 13:32
from django.db import migrations
from moneyed import CURRENCIES
import InvenTree.config
def set_currencies(apps, schema_editor):
"""Set the default currency codes.
Ref: https://github.com/inventree/InvenTree/pull/7390
Previously, the allowed currency codes were set in the external configuration
(e.g via the configuration file or environment variables).
Now, they are set in the database (via the InvenTreeSetting model).
So, this data migration exists to transfer any configured currency codes,
from the external configuration, into the database settings model.
"""
InvenTreeSetting = apps.get_model('common', 'InvenTreeSetting')
key = 'CURRENCY_CODES'
codes = InvenTree.config.get_setting('INVENTREE_CURRENCIES', 'currencies', None)
if codes is None:
# No currency codes are defined in the configuration file
return
if type(codes) == str:
codes = codes.split(',')
valid_codes = set()
for code in codes:
code = code.strip().upper()
if code in CURRENCIES:
valid_codes.add(code)
if len(valid_codes) == 0:
print(f"No valid currency codes found in configuration file")
return
value = ','.join(valid_codes)
print(f"Found existing currency codes:", value)
setting = InvenTreeSetting.objects.filter(key=key).first()
if setting:
print(f"Updating existing setting for currency codes")
setting.value = value
setting.save()
else:
print(f"Creating new setting for currency codes")
setting = InvenTreeSetting(key=key, value=value)
setting.save()
class Migration(migrations.Migration):
dependencies = [
('common', '0022_projectcode_responsible'),
]
operations = [
migrations.RunPython(set_currencies, reverse_code=migrations.RunPython.noop)
]

View File

@ -4,12 +4,10 @@ These models are 'generic' and do not fit a particular business logic object.
"""
import base64
import decimal
import hashlib
import hmac
import json
import logging
import math
import os
import re
import uuid
@ -41,6 +39,7 @@ from djmoney.settings import CURRENCY_CHOICES
from rest_framework.exceptions import PermissionDenied
import build.validators
import common.currency
import InvenTree.fields
import InvenTree.helpers
import InvenTree.models
@ -1160,39 +1159,6 @@ def validate_email_domains(setting):
raise ValidationError(_(f'Invalid domain name: {domain}'))
def currency_exchange_plugins():
"""Return a set of plugin choices which can be used for currency exchange."""
try:
from plugin import registry
plugs = registry.with_mixin('currencyexchange', active=True)
except Exception:
plugs = []
return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs]
def after_change_currency(setting):
"""Callback function when base currency is changed.
- Update exchange rates
- Recalculate prices for all parts
"""
if InvenTree.ready.isImportingData():
return
if not InvenTree.ready.canAppAccessDatabase():
return
from part import tasks as part_tasks
# Immediately update exchange rates
InvenTree.tasks.update_exchange_rates(force=True)
# Offload update of part prices to a background task
InvenTree.tasks.offload_task(part_tasks.check_missing_pricing, force_async=True)
def reload_plugin_registry(setting):
"""When a core plugin setting is changed, reload the plugin registry."""
from plugin import registry
@ -1305,8 +1271,15 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'name': _('Default Currency'),
'description': _('Select base currency for pricing calculations'),
'default': 'USD',
'choices': CURRENCY_CHOICES,
'after_save': after_change_currency,
'choices': common.currency.currency_code_mappings,
'after_save': common.currency.after_change_currency,
},
'CURRENCY_CODES': {
'name': _('Supported Currencies'),
'description': _('List of supported currency codes'),
'default': common.currency.currency_codes_default_list(),
'validator': common.currency.validate_currency_codes,
'after_save': common.currency.after_change_currency,
},
'CURRENCY_UPDATE_INTERVAL': {
'name': _('Currency Update Interval'),
@ -1320,7 +1293,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'CURRENCY_UPDATE_PLUGIN': {
'name': _('Currency Update Plugin'),
'description': _('Currency update plugin to use'),
'choices': currency_exchange_plugins,
'choices': common.currency.currency_exchange_plugins,
'default': 'inventreecurrencyexchange',
},
'INVENTREE_DOWNLOAD_FROM_URL': {
@ -2567,82 +2540,6 @@ class PriceBreak(MetaMixin):
return converted.amount
def get_price(
instance,
quantity,
moq=True,
multiples=True,
currency=None,
break_name: str = 'price_breaks',
):
"""Calculate the price based on quantity price breaks.
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too
"""
from common.settings import currency_code_default
if hasattr(instance, break_name):
price_breaks = getattr(instance, break_name).all()
else:
price_breaks = []
# No price break information available?
if len(price_breaks) == 0:
return None
# Check if quantity is fraction and disable multiples
multiples = quantity % 1 == 0
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / instance.multiple) * instance.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
if currency is None:
# Default currency selection
currency = currency_code_default()
pb_min = None
for pb in price_breaks:
# Store smallest price break
if not pb_min:
pb_min = pb
# Ignore this pricebreak (quantity is too high)
if pb.quantity > quantity:
continue
pb_found = True
# If this price-break quantity is the largest so far, use it!
if pb.quantity > pb_quantity:
pb_quantity = pb.quantity
# Convert everything to the selected currency
pb_cost = pb.convert_to(currency)
# Use smallest price break
if not pb_found and pb_min:
# Update price break information
pb_quantity = pb_min.quantity
pb_cost = pb_min.convert_to(currency)
# Trigger cost calculation using smallest price break
pb_found = True
# Convert quantity to decimal.Decimal format
quantity = decimal.Decimal(f'{quantity}')
if pb_found:
cost = pb_cost * quantity
return InvenTree.helpers.normalize(cost + instance.base_cost)
return None
class ColorTheme(models.Model):
"""Color Theme Setting."""

View File

@ -1,61 +1,5 @@
"""User-configurable settings for the common app."""
import logging
from django.conf import settings
from django.core.cache import cache
from moneyed import CURRENCIES
logger = logging.getLogger('inventree')
def currency_code_default():
"""Returns the default currency code (or USD if not specified)."""
from common.models import InvenTreeSetting
try:
cached_value = cache.get('currency_code_default', '')
except Exception:
cached_value = None
if cached_value:
return cached_value
try:
code = InvenTreeSetting.get_setting(
'INVENTREE_DEFAULT_CURRENCY', backup_value='', create=True, cache=True
)
except Exception: # pragma: no cover
# Database may not yet be ready, no need to throw an error here
code = ''
if code not in CURRENCIES:
code = 'USD' # pragma: no cover
# Cache the value for a short amount of time
try:
cache.set('currency_code_default', code, 30)
except Exception:
pass
return code
def all_currency_codes():
"""Returns a list of all currency codes."""
return [(a, CURRENCIES[a].name) for a in CURRENCIES]
def currency_code_mappings():
"""Returns the current currency choices."""
return [(a, CURRENCIES[a].name) for a in settings.CURRENCIES]
def currency_codes():
"""Returns the current currency codes."""
return list(settings.CURRENCIES)
def stock_expiry_enabled():
"""Returns True if the stock expiry feature is enabled."""

View File

@ -372,6 +372,30 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
# Number of results should match the number of settings
self.assertEqual(len(response.data), n_public_settings)
def test_currency_settings(self):
"""Run tests for currency specific settings."""
url = reverse('api-global-setting-detail', kwargs={'key': 'CURRENCY_CODES'})
response = self.patch(url, data={'value': 'USD,XYZ'}, expected_code=400)
self.assertIn("Invalid currency code: 'XYZ'", str(response.data))
response = self.patch(
url, data={'value': 'AUD,USD, AUD,AUD,'}, expected_code=400
)
self.assertIn("Duplicate currency code: 'AUD'", str(response.data))
response = self.patch(url, data={'value': ',,,,,'}, expected_code=400)
self.assertIn('No valid currency codes provided', str(response.data))
response = self.patch(url, data={'value': 'AUD,USD,GBP'}, expected_code=200)
codes = InvenTreeSetting.get_setting('CURRENCY_CODES')
self.assertEqual(codes, 'AUD,USD,GBP')
def test_company_name(self):
"""Test a settings object lifecycle e2e."""
setting = InvenTreeSetting.get_setting_object('INVENTREE_COMPANY_NAME')

View File

@ -2,6 +2,7 @@
from django.db import migrations, connection
import djmoney.models.fields
import common.currency
import common.settings
@ -16,11 +17,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='supplierpricebreak',
name='price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
),
migrations.AddField(
model_name='supplierpricebreak',
name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.settings.currency_code_mappings(), default=common.settings.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
),
]

View File

@ -1,6 +1,7 @@
# Generated by Django 3.2.4 on 2021-07-02 13:21
import InvenTree.validators
import common.currency
import common.settings
from django.db import migrations, models
@ -15,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='company',
name='currency',
field=models.CharField(blank=True, default=common.settings.currency_code_default, help_text='Default currency used for this company', max_length=3, validators=[InvenTree.validators.validate_currency_code], verbose_name='Currency'),
field=models.CharField(blank=True, default=common.currency.currency_code_default, help_text='Default currency used for this company', max_length=3, validators=[InvenTree.validators.validate_currency_code], verbose_name='Currency'),
),
]

View File

@ -1,7 +1,6 @@
"""Company database model definitions."""
import os
from datetime import datetime
from decimal import Decimal
from django.apps import apps
@ -20,6 +19,7 @@ from moneyed import CURRENCIES
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.currency
import common.models
import common.settings
import InvenTree.conversion
@ -29,7 +29,7 @@ import InvenTree.models
import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
from common.settings import currency_code_default
from common.currency import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from order.status_codes import PurchaseOrderStatusGroups
@ -212,7 +212,7 @@ class Company(
code = self.currency
if code not in CURRENCIES:
code = common.settings.currency_code_default()
code = common.currency.currency_code_default()
return code
@ -967,7 +967,7 @@ class SupplierPart(
SupplierPriceBreak.objects.create(part=self, quantity=quantity, price=price)
get_price = common.models.get_price
get_price = common.currency.get_price
def open_orders(self):
"""Return a database query for PurchaseOrder line items for this SupplierPart, limited to purchase orders that are open / outstanding."""

View File

@ -46,10 +46,7 @@ language: en-us
timezone: UTC
# Base URL for the InvenTree server (or use the environment variable INVENTREE_SITE_URL)
# site_url: 'http://localhost:8000'
# Base currency code (or use env var INVENTREE_BASE_CURRENCY)
base_currency: USD
site_url: 'http://localhost:8000'
# Add new user on first startup by either adding values here or from a file
#admin_user: admin
@ -57,17 +54,6 @@ base_currency: USD
#admin_password: inventree
#admin_password_file: '/etc/inventree/admin_password.txt'
# List of currencies supported by default. Add other currencies here to allow use in InvenTree
currencies:
- AUD
- CAD
- CNY
- EUR
- GBP
- JPY
- NZD
- USD
# Email backend configuration
# Ref: https://docs.djangoproject.com/en/dev/topics/email/
# Alternatively, these options can all be set using environment variables,

View File

@ -3,6 +3,7 @@
from decimal import Decimal
from typing import cast
from django.conf import settings
from django.contrib.auth import authenticate, login
from django.db import transaction
from django.db.models import F, Q
@ -17,7 +18,6 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
import common.models as common_models
from common.settings import settings
from company.models import SupplierPart
from generic.states.api import StatusView
from InvenTree.api import (

View File

@ -2,6 +2,7 @@
from django.db import migrations
import djmoney.models.fields
import common.currency
import common.settings
@ -16,11 +17,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='purchaseorderlineitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
),
migrations.AddField(
model_name='purchaseorderlineitem',
name='purchase_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.settings.currency_code_mappings(), default=common.settings.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
),
]

View File

@ -2,6 +2,7 @@
from django.db import migrations
import djmoney.models.fields
import common.currency
import common.settings
@ -15,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='purchaseorderlineitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit purchase price', max_digits=19, null=True, verbose_name='Purchase Price'),
),
]

View File

@ -1,6 +1,7 @@
# Generated by Django 3.2 on 2021-05-04 19:46
from django.db import migrations
import common.currency
import common.settings
import djmoney.models.fields
@ -15,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='salesorderlineitem',
name='sale_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'),
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit sale price', max_digits=19, null=True, verbose_name='Sale Price'),
),
migrations.AddField(
model_name='salesorderlineitem',
name='sale_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.settings.currency_code_mappings(), default=common.settings.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
),
]

View File

@ -8,7 +8,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from common.settings import currency_code_default
from common.currency import currency_code_default
logger = logging.getLogger('inventree')

View File

@ -33,8 +33,8 @@ import order.validators
import report.mixins
import stock.models
import users.models as UserModels
from common.currency import currency_code_default
from common.notifications import InvenTreeNotificationBodies
from common.settings import currency_code_default
from company.models import Address, Company, Contact, SupplierPart
from generic.states import StateTransitionMixin
from InvenTree.exceptions import log_error

View File

@ -13,8 +13,8 @@ from djmoney.money import Money
from icalendar import Calendar
from rest_framework import status
from common.currency import currency_codes
from common.models import InvenTreeSetting
from common.settings import currency_codes
from company.models import Company, SupplierPart, SupplierPriceBreak
from InvenTree.unit_test import InvenTreeAPITestCase
from order import models

View File

@ -2,7 +2,7 @@
from django.db import migrations
import djmoney.models.fields
import common.settings
import common.currency
class Migration(migrations.Migration):
@ -16,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='partsellpricebreak',
name='price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price'),
),
migrations.AddField(
model_name='partsellpricebreak',
name='price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.settings.currency_code_mappings(), default=common.settings.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3),
),
]

View File

@ -2,6 +2,7 @@
import InvenTree.fields
import django.core.validators
import common.currency
import common.settings
from django.db import migrations, models
import django.db.models.deletion
@ -20,8 +21,8 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Price break quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Quantity')),
('price_currency', djmoney.models.fields.CurrencyField(choices=common.settings.currency_code_mappings(), default=common.settings.currency_code_default(), editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
('price_currency', djmoney.models.fields.CurrencyField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default(), editable=False, max_length=3)),
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internalpricebreaks', to='part.part', verbose_name='Part')),
],
options={

View File

@ -1,13 +1,14 @@
# Generated by Django 3.2.16 on 2022-11-12 01:28
import InvenTree.fields
import common.settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
import djmoney.models.validators
import InvenTree.fields
import common.currency
import common.settings
class Migration(migrations.Migration):
@ -35,7 +36,7 @@ class Migration(migrations.Migration):
name='PartPricing',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency', models.CharField(choices=common.settings.currency_code_mappings(), default=common.settings.currency_code_default, help_text='Currency used to cache pricing calculations', max_length=10, verbose_name='Currency')),
('currency', models.CharField(choices=common.currency.currency_code_mappings(), default=common.currency.currency_code_default, help_text='Currency used to cache pricing calculations', max_length=10, verbose_name='Currency')),
('updated', models.DateTimeField(auto_now=True, help_text='Timestamp of last pricing update', verbose_name='Updated')),
('scheduled_for_update', models.BooleanField(default=False)),
('bom_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),

View File

@ -33,6 +33,7 @@ from mptt.models import MPTTModel, TreeForeignKey
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import common.currency
import common.models
import common.settings
import InvenTree.conversion
@ -47,8 +48,8 @@ import report.mixins
import users.models
from build import models as BuildModels
from build.status_codes import BuildStatusGroups
from common.currency import currency_code_default
from common.models import InvenTreeSetting
from common.settings import currency_code_default
from company.models import SupplierPart
from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeURLField
@ -2018,7 +2019,7 @@ class Part(
help_text=_('Sell multiple'),
)
get_price = common.models.get_price
get_price = common.currency.get_price
@property
def has_price_breaks(self):
@ -2050,7 +2051,7 @@ class Part(
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
"""Return the internal price of this Part at the specified quantity."""
return common.models.get_price(
return common.currency.get_price(
self, quantity, moq, multiples, currency, break_name='internal_price_breaks'
)
@ -2646,7 +2647,7 @@ class PartPricing(common.models.MetaMixin):
# Short circuit - no further operations required
return
currency_code = common.settings.currency_code_default()
currency_code = common.currency.currency_code_default()
cumulative_min = Money(0, currency_code)
cumulative_max = Money(0, currency_code)
@ -3025,7 +3026,7 @@ class PartPricing(common.models.MetaMixin):
max_length=10,
verbose_name=_('Currency'),
help_text=_('Currency used to cache pricing calculations'),
choices=common.settings.currency_code_mappings(),
choices=common.currency.currency_code_mappings(),
)
scheduled_for_update = models.BooleanField(default=False)

View File

@ -21,6 +21,7 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
from taggit.serializers import TagListSerializerField
import common.currency
import common.models
import common.settings
import company.models
@ -1286,7 +1287,7 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
label=_('Minimum price currency'),
read_only=False,
required=False,
choices=common.settings.currency_code_mappings(),
choices=common.currency.currency_code_mappings(),
)
override_max = InvenTree.serializers.InvenTreeMoneySerializer(
@ -1301,7 +1302,7 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
label=_('Maximum price currency'),
read_only=False,
required=False,
choices=common.settings.currency_code_mappings(),
choices=common.currency.currency_code_mappings(),
)
overall_min = InvenTree.serializers.InvenTreeMoneySerializer(
@ -1342,7 +1343,7 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
override_min = data.get('override_min', None)
override_max = data.get('override_max', None)
default_currency = common.settings.currency_code_default()
default_currency = common.currency.currency_code_default()
if override_min is not None and override_max is not None:
try:

View File

@ -13,6 +13,7 @@ import tablib
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
import common.currency
import common.models
import InvenTree.helpers
import part.models
@ -67,7 +68,7 @@ def perform_stocktake(
pricing.update_pricing(cascade=False)
pricing.refresh_from_db()
base_currency = common.settings.currency_code_default()
base_currency = common.currency.currency_code_default()
# Keep track of total quantity and cost for this part
total_quantity = 0
@ -210,7 +211,7 @@ def generate_stocktake_report(**kwargs):
logger.info('Generating new stocktake report for %s parts', n_parts)
base_currency = common.settings.currency_code_default()
base_currency = common.currency.currency_code_default()
# Construct an initial dataset for the stocktake report
dataset = tablib.Dataset(

View File

@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import common.currency
import common.models
import common.notifications
import common.settings
@ -110,7 +111,7 @@ def check_missing_pricing(limit=250):
pp.schedule_for_update()
# Find any pricing data which is in the wrong currency
currency = common.settings.currency_code_default()
currency = common.currency.currency_code_default()
results = part.models.PartPricing.objects.exclude(currency=currency)
if results.count() > 0:

View File

@ -5,6 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
import common.currency
import common.models
import common.settings
import company.models
@ -179,7 +180,7 @@ class PartPricingTests(InvenTreeTestCase):
self.assertIsNone(pricing.internal_cost_min)
self.assertIsNone(pricing.internal_cost_max)
currency = common.settings.currency_code_default()
currency = common.currency.currency_code_default()
for ii in range(5):
# Let's add some internal price breaks

View File

@ -2,7 +2,7 @@
from django.db import migrations
import djmoney.models.fields
import common.settings
import common.currency
class Migration(migrations.Migration):
@ -16,11 +16,11 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='stockitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
),
migrations.AddField(
model_name='stockitem',
name='purchase_price_currency',
field=djmoney.models.fields.CurrencyField(choices=common.settings.all_currency_codes(), default=common.settings.currency_code_default(), editable=False, max_length=3),
field=djmoney.models.fields.CurrencyField(choices=common.currency.all_currency_codes(), default=common.currency.currency_code_default(), editable=False, max_length=3),
),
]

View File

@ -2,7 +2,7 @@
from django.db import migrations
import djmoney.models.fields
import common.settings
import common.currency
class Migration(migrations.Migration):
@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='stockitem',
name='purchase_price',
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.settings.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
field=djmoney.models.fields.MoneyField(blank=True, decimal_places=4, default_currency=common.currency.currency_code_default(), help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, verbose_name='Purchase Price'),
),
]

View File

@ -12,6 +12,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %}
{% include "InvenTree/settings/setting.html" with key="CURRENCY_CODES" %}
{% include "InvenTree/settings/setting.html" with key="PART_INTERNAL_PRICE" %}
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
{% include "InvenTree/settings/setting.html" with key="PRICING_DECIMAL_PLACES_MIN" icon='fa-dollar-sign' %}

View File

@ -109,6 +109,7 @@ export default function SystemSettings() {
<GlobalSettingList
keys={[
'INVENTREE_DEFAULT_CURRENCY',
'CURRENCY_CODES',
'PART_INTERNAL_PRICE',
'PART_BOM_USE_INTERNAL_PRICE',
'PRICING_DECIMAL_PLACES_MIN',