diff --git a/InvenTree/InvenTree/exchange.py b/InvenTree/InvenTree/exchange.py index 3889ebf157..f447af07f7 100644 --- a/InvenTree/InvenTree/exchange.py +++ b/InvenTree/InvenTree/exchange.py @@ -1,81 +1,102 @@ -"""Exchangerate backend to use `frankfurter.app` to get rates.""" +"""Custom exchange backend which hooks into the InvenTree plugin system to fetch exchange rates from an external API.""" -from decimal import Decimal -from urllib.error import URLError +import logging -from django.db.utils import OperationalError +from django.db.transaction import atomic -import requests 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 +logger = logging.getLogger('inventree') + class InvenTreeExchange(SimpleExchangeBackend): """Backend for automatically updating currency exchange rates. - Uses the `frankfurter.app` service API + Uses the plugin system to actually fetch the rates from an external API. """ name = "InvenTreeExchange" - def __init__(self): - """Set API url.""" - self.url = "https://api.frankfurter.app/latest" + def get_rates(self, **kwargs) -> None: + """Set the requested currency codes and get rates.""" - super().__init__() + from common.models import InvenTreeSetting + from plugin import registry - def get_params(self): - """Placeholder to set API key. Currently not required by `frankfurter.app`.""" - # No API key is required - return { - } + base_currency = kwargs.get('base_currency', currency_code_default()) + symbols = kwargs.get('symbols', currency_codes()) - def get_response(self, **kwargs): - """Custom code to get response from server. + # Find the selected exchange rate plugin + slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False) - Note: Adds a 5-second timeout - """ - url = self.get_url(**kwargs) + if slug: + plugin = registry.get_plugin(slug) + else: + plugin = None + if not plugin: + # Find the first active currency exchange plugin + plugins = registry.with_mixin('currencyexchange', active=True) + + if len(plugins) > 0: + plugin = plugins[0] + + if not plugin: + logger.warning('No active currency exchange plugins found - skipping update') + return {} + + logger.info("Running exchange rate update using plugin '%s'", plugin.name) + + # Plugin found - run the update task try: - response = requests.get(url=url, timeout=5) - return response.content - except Exception: - # Something has gone wrong, but we can just try again next time - # Raise a TypeError so the outer function can handle this - raise TypeError + rates = plugin.update_exchange_rates(base_currency, symbols) + except Exception as exc: + logger.exception("Exchange rate update failed: %s", exc) + return {} - def get_rates(self, **params): - """Intersect the requested currency codes with the available codes.""" - rates = super().get_rates(**params) + if not rates: + logger.warning("Exchange rate update failed - no data returned from plugin %s", slug) + return {} - # Add the base currency to the rates - rates[params["base_currency"]] = Decimal("1.0") + # Update exchange rates based on returned data + if type(rates) is not dict: + logger.warning("Invalid exchange rate data returned from plugin %s (type %s)", slug, type(rates)) + return {} + + # Ensure base currency is provided + rates[base_currency] = 1.00 return rates - def update_rates(self, base_currency=None): - """Set the requested currency codes and get rates.""" - # Set default - see B008 + @atomic + def update_rates(self, base_currency=None, **kwargs): + """Call to update all exchange rates""" + + backend, _ = ExchangeBackend.objects.update_or_create(name=self.name, defaults={"base_currency": base_currency}) + if base_currency is None: base_currency = currency_code_default() - symbols = ','.join(currency_codes()) + symbols = currency_codes() - try: - super().update_rates(base=base_currency, symbols=symbols) - # catch connection errors - except URLError: - print('Encountered connection error while updating') - except TypeError: - print('Exchange returned invalid response') - except OperationalError as e: - if 'SerializationFailure' in e.__cause__.__class__.__name__: - print('Serialization Failure while updating exchange rates') - # We are just going to swallow this exception because the - # exchange rates will be updated later by the scheduled task - else: - # Other operational errors probably are still show stoppers - # so reraise them so that the log contains the stacktrace - raise + logger.info("Updating exchange rates for %s (%s currencies)", base_currency, len(symbols)) + + # Fetch new rates from the backend + # If the backend fails, the existing rates will not be updated + rates = self.get_rates(base_currency=base_currency, symbols=symbols) + + if rates: + # Clear out existing rates + backend.clear_rates() + + Rate.objects.bulk_create([ + Rate(currency=currency, value=amount, backend=backend) + for currency, amount in rates.items() + ]) + else: + logger.info("No exchange rates returned from backend - currencies not updated") + + logger.info("Updated exchange rates for %s", base_currency) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 12ad120e30..794666aaa6 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -497,21 +497,34 @@ def check_for_updates(): @scheduled_task(ScheduledTask.DAILY) -def update_exchange_rates(): - """Update currency exchange rates.""" +def update_exchange_rates(force: bool = False): + """Update currency exchange rates + + Arguments: + force: If True, force the update to run regardless of the last update time + """ + try: from djmoney.contrib.exchange.models import Rate + 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! logger.info("Could not perform 'update_exchange_rates' - App registry not ready") return - except Exception: # pragma: no cover - # Other error? + except Exception as exc: # pragma: no cover + logger.info("Could not perform 'update_exchange_rates' - %s", exc) return + if not force: + interval = int(InvenTreeSetting.get_setting('CURRENCY_UPDATE_INTERVAL', 1, cache=False)) + + if not check_daily_holdoff('update_exchange_rates', interval): + logger.info("Skipping exchange rate update (interval not reached)") + return + backend = InvenTreeExchange() base = currency_code_default() logger.info("Updating exchange rates using base currency '%s'", base) @@ -521,10 +534,14 @@ def update_exchange_rates(): # Remove any exchange rates which are not in the provided currencies Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete() + + # Record successful task execution + record_task_success('update_exchange_rates') + except OperationalError: logger.warning("Could not update exchange rates - database not ready") except Exception as e: # pragma: no cover - logger.exception("Error updating exchange rates: %s (%s)", e, type(e)) + logger.exception("Error updating exchange rates: %s", str(type(e))) @scheduled_task(ScheduledTask.DAILY) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 3e17af11c9..d77bddc3ca 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -160,7 +160,7 @@ class CurrencyRefreshView(APIView): from InvenTree.tasks import update_exchange_rates - update_exchange_rates() + update_exchange_rates(force=True) return Response({ 'success': 'Exchange rates updated', @@ -192,6 +192,12 @@ class GlobalSettingsList(SettingsList): queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_") serializer_class = common.serializers.GlobalSettingsSerializer + def list(self, request, *args, **kwargs): + """Ensure all global settings are created""" + + common.models.InvenTreeSetting.build_default_values() + return super().list(request, *args, **kwargs) + class GlobalSettingsPermissions(permissions.BasePermission): """Special permission class to determine if the user is "staff".""" @@ -245,6 +251,12 @@ class UserSettingsList(SettingsList): queryset = common.models.InvenTreeUserSetting.objects.all() serializer_class = common.serializers.UserSettingsSerializer + def list(self, request, *args, **kwargs): + """Ensure all user settings are created""" + + common.models.InvenTreeUserSetting.build_default_values(user=request.user) + return super().list(request, *args, **kwargs) + def filter_queryset(self, queryset): """Only list settings which apply to the current user.""" try: diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 0531d886dd..11dab46995 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -197,6 +197,32 @@ class BaseInvenTreeSetting(models.Model): # Execute after_save action self._call_settings_function('after_save', args, kwargs) + @classmethod + def build_default_values(cls, **kwargs): + """Ensure that all values defined in SETTINGS are present in the database + + If a particular setting is not present, create it with the default value + """ + + try: + existing_keys = cls.objects.filter(**kwargs).values_list('key', flat=True) + settings_keys = cls.SETTINGS.keys() + + missing_keys = set(settings_keys) - set(existing_keys) + + if len(missing_keys) > 0: + logger.info("Building %s default values for %s", len(missing_keys), str(cls)) + cls.objects.bulk_create([ + cls( + key=key, + value=cls.get_setting_default(key), + **kwargs + ) for key in missing_keys if not key.startswith('_') + ]) + except Exception as exc: + logger.exception("Failed to build default values for %s (%s)", str(cls), str(type(exc))) + pass + def _call_settings_function(self, reference: str, args, kwargs): """Call a function associated with a particular setting. @@ -939,6 +965,20 @@ 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 update_exchange_rates(setting): """Update exchange rates when base currency is changed""" @@ -948,7 +988,7 @@ def update_exchange_rates(setting): if not InvenTree.ready.canAppAccessDatabase(): return - InvenTree.tasks.update_exchange_rates() + InvenTree.tasks.update_exchange_rates(force=True) def reload_plugin_registry(setting): @@ -1053,6 +1093,24 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'after_save': update_exchange_rates, }, + 'CURRENCY_UPDATE_INTERVAL': { + 'name': _('Currency Update Interval'), + 'description': _('How often to update exchange rates (set to zero to disable)'), + 'default': 1, + 'units': _('days'), + 'validator': [ + int, + MinValueValidator(0), + ], + }, + + 'CURRENCY_UPDATE_PLUGIN': { + 'name': _('Currency Update Plugin'), + 'description': _('Currency update plugin to use'), + 'choices': currency_exchange_plugins, + 'default': 'inventreecurrencyexchange' + }, + 'INVENTREE_DOWNLOAD_FROM_URL': { 'name': _('Download from URL'), 'description': _('Allow download of remote images and files from external URL'), diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 39f0dfbb58..42f02c0968 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -52,7 +52,7 @@ class PluginConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields.""" readonly_fields = ["key", "name", ] - list_display = ['name', 'key', '__str__', 'active', 'is_builtin', 'is_sample'] + list_display = ['name', 'key', '__str__', 'active', 'is_builtin', 'is_sample', 'is_installed'] list_filter = ['active'] actions = [plugin_activate, plugin_deactivate, ] inlines = [PluginSettingInline, ] diff --git a/InvenTree/plugin/base/integration/APICallMixin.py b/InvenTree/plugin/base/integration/APICallMixin.py new file mode 100644 index 0000000000..7fa6a43d25 --- /dev/null +++ b/InvenTree/plugin/base/integration/APICallMixin.py @@ -0,0 +1,170 @@ +"""Mixin class for making calls to an external API""" + + +import json as json_pkg +import logging + +import requests + +from plugin.helpers import MixinNotImplementedError + +logger = logging.getLogger('inventree') + + +class APICallMixin: + """Mixin that enables easier API calls for a plugin. + + Steps to set up: + 1. Add this mixin before (left of) SettingsMixin and PluginBase + 2. Add two settings for the required url and token/password (use `SettingsMixin`) + 3. Save the references to keys of the settings in `API_URL_SETTING` and `API_TOKEN_SETTING` + 4. (Optional) Set `API_TOKEN` to the name required for the token by the external API - Defaults to `Bearer` + 5. (Optional) Override the `api_url` property method if the setting needs to be extended + 6. (Optional) Override `api_headers` to add extra headers (by default the token and Content-Type are contained) + 7. Access the API in you plugin code via `api_call` + + Example: + ``` + from plugin import InvenTreePlugin + from plugin.mixins import APICallMixin, SettingsMixin + + + class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin): + ''' + A small api call sample + ''' + NAME = "Sample API Caller" + + SETTINGS = { + 'API_TOKEN': { + 'name': 'API Token', + 'protected': True, + }, + 'API_URL': { + 'name': 'External URL', + 'description': 'Where is your API located?', + 'default': 'reqres.in', + }, + } + API_URL_SETTING = 'API_URL' + API_TOKEN_SETTING = 'API_TOKEN' + + def get_external_url(self): + ''' + returns data from the sample endpoint + ''' + return self.api_call('api/users/2') + ``` + """ + API_METHOD = 'https' + API_URL_SETTING = None + API_TOKEN_SETTING = None + + API_TOKEN = 'Bearer' + + class MixinMeta: + """Meta options for this mixin.""" + MIXIN_NAME = 'API calls' + + def __init__(self): + """Register mixin.""" + super().__init__() + self.add_mixin('api_call', 'has_api_call', __class__) + + @property + def has_api_call(self): + """Is the mixin ready to call external APIs?""" + if not bool(self.API_URL_SETTING): + raise MixinNotImplementedError("API_URL_SETTING must be defined") + if not bool(self.API_TOKEN_SETTING): + raise MixinNotImplementedError("API_TOKEN_SETTING must be defined") + return True + + @property + def api_url(self): + """Base url path.""" + return f'{self.API_METHOD}://{self.get_setting(self.API_URL_SETTING)}' + + @property + def api_headers(self): + """Returns the default headers for requests with api_call. + + Contains a header with the key set in `API_TOKEN` for the plugin it `API_TOKEN_SETTING` is defined. + Check the mixin class docstring for a full example. + """ + headers = {'Content-Type': 'application/json'} + if getattr(self, 'API_TOKEN_SETTING'): + token = self.get_setting(self.API_TOKEN_SETTING) + + if token: + headers[self.API_TOKEN] = token + headers['Authorization'] = f"{self.API_TOKEN} {token}" + + return headers + + def api_build_url_args(self, arguments: dict) -> str: + """Returns an encoded path for the provided dict.""" + groups = [] + for key, val in arguments.items(): + groups.append(f'{key}={",".join([str(a) for a in val])}') + return f'?{"&".join(groups)}' + + def api_call(self, endpoint: str, method: str = 'GET', url_args: dict = None, data=None, json=None, headers: dict = None, simple_response: bool = True, endpoint_is_url: bool = False): + """Do an API call. + + Simplest call example: + ```python + self.api_call('hello') + ``` + Will call the `{base_url}/hello` with a GET request and - if set - the token for this plugin. + + Args: + endpoint (str): Path to current endpoint. Either the endpoint or the full or if the flag is set + method (str, optional): HTTP method that should be uses - capitalized. Defaults to 'GET'. + url_args (dict, optional): arguments that should be appended to the url. Defaults to None. + data (Any, optional): Data that should be transmitted in the body - url-encoded. Defaults to None. + json (Any, optional): Data that should be transmitted in the body - must be JSON serializable. Defaults to None. + headers (dict, optional): Headers that should be used for the request. Defaults to self.api_headers. + simple_response (bool, optional): Return the response as JSON. Defaults to True. + endpoint_is_url (bool, optional): The provided endpoint is the full url - do not use self.api_url as base. Defaults to False. + + Returns: + Response + """ + if url_args: + endpoint += self.api_build_url_args(url_args) + + if headers is None: + headers = self.api_headers + + if endpoint_is_url: + url = endpoint + else: + + if endpoint.startswith('/'): + endpoint = endpoint[1:] + + url = f'{self.api_url}/{endpoint}' + + # build kwargs for call + kwargs = { + 'url': url, + 'headers': headers, + } + + if data and json: + raise ValueError('You can either pass `data` or `json` to this function.') + + if json: + kwargs['data'] = json_pkg.dumps(json) + + if data: + kwargs['data'] = data + + # run command + response = requests.request(method, **kwargs) + + # return + if simple_response: + return response.json() + return response diff --git a/InvenTree/plugin/base/integration/CurrencyExchangeMixin.py b/InvenTree/plugin/base/integration/CurrencyExchangeMixin.py new file mode 100644 index 0000000000..3ed67f5707 --- /dev/null +++ b/InvenTree/plugin/base/integration/CurrencyExchangeMixin.py @@ -0,0 +1,42 @@ +"""Plugin mixin class for supporting currency exchange data""" + + +from plugin.helpers import MixinNotImplementedError + + +class CurrencyExchangeMixin: + """Mixin class which provides support for currency exchange rates + + Nominally this plugin mixin would be used to interface with an external API, + to periodically retrieve currency exchange rate information. + + The plugin class *must* implement the update_exchange_rates method, + which is called periodically by the background worker thread. + """ + + class MixinMeta: + """Meta options for this mixin class""" + + MIXIN_NAME = "CurrentExchange" + + def __init__(self): + """Register the mixin""" + super().__init__() + self.add_mixin('currencyexchange', True, __class__) + + def update_exchange_rates(self, base_currency: str, symbols: list[str]) -> dict: + """Update currency exchange rates. + + This method *must* be implemented by the plugin class. + + Arguments: + base_currency: The base currency to use for exchange rates + symbols: A list of currency symbols to retrieve exchange rates for + + Returns: + A dictionary of exchange rates, or None if the update failed + + Raises: + Can raise any exception if the update fails + """ + raise MixinNotImplementedError("Plugin must implement update_exchange_rates method") diff --git a/InvenTree/plugin/base/integration/ValidationMixin.py b/InvenTree/plugin/base/integration/ValidationMixin.py new file mode 100644 index 0000000000..caf015aa4c --- /dev/null +++ b/InvenTree/plugin/base/integration/ValidationMixin.py @@ -0,0 +1,158 @@ +"""Validation mixin class definition""" + + +import part.models +import stock.models + + +class ValidationMixin: + """Mixin class that allows custom validation for various parts of InvenTree + + Custom generation and validation functionality can be provided for: + + - Part names + - Part IPN (internal part number) values + - Part parameter values + - Serial numbers + - Batch codes + + Notes: + - Multiple ValidationMixin plugins can be used simultaneously + - The stub methods provided here generally return None (null value). + - The "first" plugin to return a non-null value for a particular method "wins" + - In the case of "validation" functions, all loaded plugins are checked until an exception is thrown + + Implementing plugins may override any of the following methods which are of interest. + + For 'validation' methods, there are three 'acceptable' outcomes: + - The method determines that the value is 'invalid' and raises a django.core.exceptions.ValidationError + - The method passes and returns None (the code then moves on to the next plugin) + - The method passes and returns True (and no subsequent plugins are checked) + + """ + + class MixinMeta: + """Metaclass for this mixin""" + MIXIN_NAME = "Validation" + + def __init__(self): + """Register the mixin""" + super().__init__() + self.add_mixin('validation', True, __class__) + + def validate_part_name(self, name: str, part: part.models.Part): + """Perform validation on a proposed Part name + + Arguments: + name: The proposed part name + part: The part instance we are validating against + + Returns: + None or True (refer to class docstring) + + Raises: + ValidationError if the proposed name is objectionable + """ + return None + + def validate_part_ipn(self, ipn: str, part: part.models.Part): + """Perform validation on a proposed Part IPN (internal part number) + + Arguments: + ipn: The proposed part IPN + part: The Part instance we are validating against + + Returns: + None or True (refer to class docstring) + + Raises: + ValidationError if the proposed IPN is objectionable + """ + return None + + def validate_batch_code(self, batch_code: str, item: stock.models.StockItem): + """Validate the supplied batch code + + Arguments: + batch_code: The proposed batch code (string) + item: The StockItem instance we are validating against + + Returns: + None or True (refer to class docstring) + + Raises: + ValidationError if the proposed batch code is objectionable + """ + return None + + def generate_batch_code(self): + """Generate a new batch code + + Returns: + A new batch code (string) or None + """ + return None + + def validate_serial_number(self, serial: str, part: part.models.Part): + """Validate the supplied serial number. + + Arguments: + serial: The proposed serial number (string) + part: The Part instance for which this serial number is being validated + + Returns: + None or True (refer to class docstring) + + Raises: + ValidationError if the proposed serial is objectionable + """ + return None + + def convert_serial_to_int(self, serial: str): + """Convert a serial number (string) into an integer representation. + + This integer value is used for efficient sorting based on serial numbers. + + A plugin which implements this method can either return: + + - An integer based on the serial string, according to some algorithm + - A fixed value, such that serial number sorting reverts to the string representation + - None (null value) to let any other plugins perform the conversion + + Note that there is no requirement for the returned integer value to be unique. + + Arguments: + serial: Serial value (string) + + Returns: + integer representation of the serial number, or None + """ + return None + + def increment_serial_number(self, serial: str): + """Return the next sequential serial based on the provided value. + + A plugin which implements this method can either return: + + - A string which represents the "next" serial number in the sequence + - None (null value) if the next value could not be determined + + Arguments: + serial: Current serial value (string) + """ + return None + + def validate_part_parameter(self, parameter, data): + """Validate a parameter value. + + Arguments: + parameter: The parameter we are validating + data: The proposed parameter value + + Returns: + None or True (refer to class docstring) + + Raises: + ValidationError if the proposed parameter value is objectionable + """ + pass diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index f81815f6de..e4250a2da6 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -1,12 +1,7 @@ """Plugin mixin classes.""" -import json as json_pkg import logging -import requests - -import part.models -import stock.models from InvenTree.helpers import generateTestKey from plugin.helpers import (MixinNotImplementedError, render_template, render_text) @@ -14,159 +9,6 @@ from plugin.helpers import (MixinNotImplementedError, render_template, logger = logging.getLogger('inventree') -class ValidationMixin: - """Mixin class that allows custom validation for various parts of InvenTree - - Custom generation and validation functionality can be provided for: - - - Part names - - Part IPN (internal part number) values - - Part parameter values - - Serial numbers - - Batch codes - - Notes: - - Multiple ValidationMixin plugins can be used simultaneously - - The stub methods provided here generally return None (null value). - - The "first" plugin to return a non-null value for a particular method "wins" - - In the case of "validation" functions, all loaded plugins are checked until an exception is thrown - - Implementing plugins may override any of the following methods which are of interest. - - For 'validation' methods, there are three 'acceptable' outcomes: - - The method determines that the value is 'invalid' and raises a django.core.exceptions.ValidationError - - The method passes and returns None (the code then moves on to the next plugin) - - The method passes and returns True (and no subsequent plugins are checked) - - """ - - class MixinMeta: - """Metaclass for this mixin""" - MIXIN_NAME = "Validation" - - def __init__(self): - """Register the mixin""" - super().__init__() - self.add_mixin('validation', True, __class__) - - def validate_part_name(self, name: str, part: part.models.Part): - """Perform validation on a proposed Part name - - Arguments: - name: The proposed part name - part: The part instance we are validating against - - Returns: - None or True (refer to class docstring) - - Raises: - ValidationError if the proposed name is objectionable - """ - return None - - def validate_part_ipn(self, ipn: str, part: part.models.Part): - """Perform validation on a proposed Part IPN (internal part number) - - Arguments: - ipn: The proposed part IPN - part: The Part instance we are validating against - - Returns: - None or True (refer to class docstring) - - Raises: - ValidationError if the proposed IPN is objectionable - """ - return None - - def validate_batch_code(self, batch_code: str, item: stock.models.StockItem): - """Validate the supplied batch code - - Arguments: - batch_code: The proposed batch code (string) - item: The StockItem instance we are validating against - - Returns: - None or True (refer to class docstring) - - Raises: - ValidationError if the proposed batch code is objectionable - """ - return None - - def generate_batch_code(self): - """Generate a new batch code - - Returns: - A new batch code (string) or None - """ - return None - - def validate_serial_number(self, serial: str, part: part.models.Part): - """Validate the supplied serial number. - - Arguments: - serial: The proposed serial number (string) - part: The Part instance for which this serial number is being validated - - Returns: - None or True (refer to class docstring) - - Raises: - ValidationError if the proposed serial is objectionable - """ - return None - - def convert_serial_to_int(self, serial: str): - """Convert a serial number (string) into an integer representation. - - This integer value is used for efficient sorting based on serial numbers. - - A plugin which implements this method can either return: - - - An integer based on the serial string, according to some algorithm - - A fixed value, such that serial number sorting reverts to the string representation - - None (null value) to let any other plugins perform the converrsion - - Note that there is no requirement for the returned integer value to be unique. - - Arguments: - serial: Serial value (string) - - Returns: - integer representation of the serial number, or None - """ - return None - - def increment_serial_number(self, serial: str): - """Return the next sequential serial based on the provided value. - - A plugin which implements this method can either return: - - - A string which represents the "next" serial number in the sequence - - None (null value) if the next value could not be determined - - Arguments: - serial: Current serial value (string) - """ - return None - - def validate_part_parameter(self, parameter, data): - """Validate a parameter value. - - Arguments: - parameter: The parameter we are validating - data: The proposed parameter value - - Returns: - None or True (refer to class docstring) - - Raises: - ValidationError if the proposed parameter value is objectionable - """ - pass - - class NavigationMixin: """Mixin that enables custom navigation links with the plugin.""" @@ -181,7 +23,7 @@ class NavigationMixin: def __init__(self): """Register mixin.""" super().__init__() - self.add_mixin('navigation', 'has_naviation', __class__) + self.add_mixin('navigation', 'has_navigation', __class__) self.navigation = self.setup_navigation() def setup_navigation(self): @@ -195,7 +37,7 @@ class NavigationMixin: return nav_links @property - def has_naviation(self): + def has_navigation(self): """Does this plugin define navigation elements.""" return bool(self.navigation) @@ -213,165 +55,6 @@ class NavigationMixin: return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question") -class APICallMixin: - """Mixin that enables easier API calls for a plugin. - - Steps to set up: - 1. Add this mixin before (left of) SettingsMixin and PluginBase - 2. Add two settings for the required url and token/password (use `SettingsMixin`) - 3. Save the references to keys of the settings in `API_URL_SETTING` and `API_TOKEN_SETTING` - 4. (Optional) Set `API_TOKEN` to the name required for the token by the external API - Defaults to `Bearer` - 5. (Optional) Override the `api_url` property method if the setting needs to be extended - 6. (Optional) Override `api_headers` to add extra headers (by default the token and Content-Type are contained) - 7. Access the API in you plugin code via `api_call` - - Example: - ``` - from plugin import InvenTreePlugin - from plugin.mixins import APICallMixin, SettingsMixin - - - class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin): - ''' - A small api call sample - ''' - NAME = "Sample API Caller" - - SETTINGS = { - 'API_TOKEN': { - 'name': 'API Token', - 'protected': True, - }, - 'API_URL': { - 'name': 'External URL', - 'description': 'Where is your API located?', - 'default': 'reqres.in', - }, - } - API_URL_SETTING = 'API_URL' - API_TOKEN_SETTING = 'API_TOKEN' - - def get_external_url(self): - ''' - returns data from the sample endpoint - ''' - return self.api_call('api/users/2') - ``` - """ - API_METHOD = 'https' - API_URL_SETTING = None - API_TOKEN_SETTING = None - - API_TOKEN = 'Bearer' - - class MixinMeta: - """Meta options for this mixin.""" - MIXIN_NAME = 'API calls' - - def __init__(self): - """Register mixin.""" - super().__init__() - self.add_mixin('api_call', 'has_api_call', __class__) - - @property - def has_api_call(self): - """Is the mixin ready to call external APIs?""" - if not bool(self.API_URL_SETTING): - raise MixinNotImplementedError("API_URL_SETTING must be defined") - if not bool(self.API_TOKEN_SETTING): - raise MixinNotImplementedError("API_TOKEN_SETTING must be defined") - return True - - @property - def api_url(self): - """Base url path.""" - return f'{self.API_METHOD}://{self.get_setting(self.API_URL_SETTING)}' - - @property - def api_headers(self): - """Returns the default headers for requests with api_call. - - Contains a header with the key set in `API_TOKEN` for the plugin it `API_TOKEN_SETTING` is defined. - Check the mixin class docstring for a full example. - """ - headers = {'Content-Type': 'application/json'} - if getattr(self, 'API_TOKEN_SETTING'): - token = self.get_setting(self.API_TOKEN_SETTING) - - if token: - headers[self.API_TOKEN] = token - headers['Authorization'] = f"{self.API_TOKEN} {token}" - - return headers - - def api_build_url_args(self, arguments: dict) -> str: - """Returns an encoded path for the provided dict.""" - groups = [] - for key, val in arguments.items(): - groups.append(f'{key}={",".join([str(a) for a in val])}') - return f'?{"&".join(groups)}' - - def api_call(self, endpoint: str, method: str = 'GET', url_args: dict = None, data=None, json=None, headers: dict = None, simple_response: bool = True, endpoint_is_url: bool = False): - """Do an API call. - - Simplest call example: - ```python - self.api_call('hello') - ``` - Will call the `{base_url}/hello` with a GET request and - if set - the token for this plugin. - - Args: - endpoint (str): Path to current endpoint. Either the endpoint or the full or if the flag is set - method (str, optional): HTTP method that should be uses - capitalized. Defaults to 'GET'. - url_args (dict, optional): arguments that should be appended to the url. Defaults to None. - data (Any, optional): Data that should be transmitted in the body - url-encoded. Defaults to None. - json (Any, optional): Data that should be transmitted in the body - must be JSON serializable. Defaults to None. - headers (dict, optional): Headers that should be used for the request. Defaults to self.api_headers. - simple_response (bool, optional): Return the response as JSON. Defaults to True. - endpoint_is_url (bool, optional): The provided endpoint is the full url - do not use self.api_url as base. Defaults to False. - - Returns: - Response - """ - if url_args: - endpoint += self.api_build_url_args(url_args) - - if headers is None: - headers = self.api_headers - - if endpoint_is_url: - url = endpoint - else: - - if endpoint.startswith('/'): - endpoint = endpoint[1:] - - url = f'{self.api_url}/{endpoint}' - - # build kwargs for call - kwargs = { - 'url': url, - 'headers': headers, - } - - if data and json: - raise ValueError('You can either pass `data` or `json` to this function.') - - if json: - kwargs['data'] = json_pkg.dumps(json) - - if data: - kwargs['data'] = data - - # run command - response = requests.request(method, **kwargs) - - # return - if simple_response: - return response.json() - return response - - class PanelMixin: """Mixin which allows integration of custom 'panels' into a particular page. @@ -403,7 +86,7 @@ class PanelMixin: - icon : The icon to appear in the sidebar menu - content : The HTML content to appear in the panel, OR - content_template : A template file which will be rendered to produce the panel content - - javascript : The javascript content to be rendered when the panel is loade, OR + - javascript : The javascript content to be rendered when the panel is loaded, OR - javascript_template : A template file which will be rendered to produce javascript e.g. diff --git a/InvenTree/plugin/base/label/test_label_mixin.py b/InvenTree/plugin/base/label/test_label_mixin.py index af8f34ea45..e99c09c8ff 100644 --- a/InvenTree/plugin/base/label/test_label_mixin.py +++ b/InvenTree/plugin/base/label/test_label_mixin.py @@ -32,7 +32,7 @@ class LabelMixinTests(InvenTreeAPITestCase): def do_activate_plugin(self): """Activate the 'samplelabel' plugin.""" - config = registry.get_plugin('samplelabel').plugin_config() + config = registry.get_plugin('samplelabelprinter').plugin_config() config.active = True config.save() @@ -125,7 +125,7 @@ class LabelMixinTests(InvenTreeAPITestCase): self.assertEqual(len(response.data), 2) data = response.data[1] - self.assertEqual(data['key'], 'samplelabel') + self.assertEqual(data['key'], 'samplelabelprinter') def test_printing_process(self): """Test that a label can be printed.""" @@ -134,7 +134,7 @@ class LabelMixinTests(InvenTreeAPITestCase): # Lookup references part = Part.objects.first() - plugin_ref = 'samplelabel' + plugin_ref = 'samplelabelprinter' label = PartLabel.objects.first() url = self.do_url([part], plugin_ref, label) @@ -185,7 +185,7 @@ class LabelMixinTests(InvenTreeAPITestCase): def test_printing_endpoints(self): """Cover the endpoints not covered by `test_printing_process`.""" - plugin_ref = 'samplelabel' + plugin_ref = 'samplelabelprinter' # Activate the label components apps.get_app_config('label').create_labels() diff --git a/InvenTree/plugin/builtin/integration/currency_exchange.py b/InvenTree/plugin/builtin/integration/currency_exchange.py new file mode 100644 index 0000000000..79c08a0695 --- /dev/null +++ b/InvenTree/plugin/builtin/integration/currency_exchange.py @@ -0,0 +1,53 @@ +"""Builtin plugin for requesting exchange rates from an external API.""" + + +import logging + +from django.utils.translation import gettext_lazy as _ + +from plugin import InvenTreePlugin +from plugin.mixins import APICallMixin, CurrencyExchangeMixin + +logger = logging.getLogger('inventree') + + +class InvenTreeCurrencyExchange(APICallMixin, CurrencyExchangeMixin, InvenTreePlugin): + """Default InvenTree plugin for currency exchange rates. + + Fetches exchange rate information from frankfurter.app + """ + + NAME = "InvenTreeCurrencyExchange" + SLUG = "inventreecurrencyexchange" + AUTHOR = _('InvenTree contributors') + TITLE = _("InvenTree Currency Exchange") + DESCRIPTION = _("Default currency exchange integration") + VERSION = "1.0.0" + + def update_exchange_rates(self, base_currency: str, symbols: list[str]) -> dict: + """Request exchange rate data from external API""" + + response = self.api_call( + 'latest', + url_args={ + 'from': [base_currency], + 'to': symbols, + }, + simple_response=False + ) + + if response.status_code == 200: + + rates = response.json().get('rates', {}) + rates[base_currency] = 1.00 + + return rates + + else: + logger.warning("Failed to update exchange rates from %s: Server returned status %s", self.api_url, response.status_code) + return None + + @property + def api_url(self): + """Return the API URL for this plugin""" + return 'https://api.frankfurter.app' diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index f21fd6f1ac..7c7c05348f 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -5,20 +5,23 @@ from common.notifications import (BulkNotificationMethod, from plugin.base.action.mixins import ActionMixin from plugin.base.barcodes.mixins import BarcodeMixin from plugin.base.event.mixins import EventMixin +from plugin.base.integration.APICallMixin import APICallMixin from plugin.base.integration.AppMixin import AppMixin -from plugin.base.integration.mixins import (APICallMixin, NavigationMixin, - PanelMixin, SettingsContentMixin, - ValidationMixin) +from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin +from plugin.base.integration.mixins import (NavigationMixin, PanelMixin, + SettingsContentMixin) from plugin.base.integration.ReportMixin import ReportMixin from plugin.base.integration.ScheduleMixin import ScheduleMixin from plugin.base.integration.SettingsMixin import SettingsMixin from plugin.base.integration.UrlsMixin import UrlsMixin +from plugin.base.integration.ValidationMixin import ValidationMixin from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.locate.mixins import LocateMixin __all__ = [ 'APICallMixin', 'AppMixin', + 'CurrencyExchangeMixin', 'EventMixin', 'LabelPrintingMixin', 'NavigationMixin', diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index 7288cee72c..91c879caf4 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -389,7 +389,11 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): def define_package(self): """Add package info of the plugin into plugins context.""" - package = self._get_package_metadata() if self._is_package else self._get_package_commit() + + try: + package = self._get_package_metadata() if self._is_package else self._get_package_commit() + except TypeError: + package = {} # process date if package.get('date'): diff --git a/InvenTree/plugin/samples/integration/label_sample.py b/InvenTree/plugin/samples/integration/label_sample.py index 016026fc35..517d68b58f 100644 --- a/InvenTree/plugin/samples/integration/label_sample.py +++ b/InvenTree/plugin/samples/integration/label_sample.py @@ -10,8 +10,8 @@ from plugin.mixins import LabelPrintingMixin class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin): """Sample plugin which provides a 'fake' label printer endpoint.""" - NAME = "Label Printer" - SLUG = "samplelabel" + NAME = "Sample Label Printer" + SLUG = "samplelabelprinter" TITLE = "Sample Label Printer" DESCRIPTION = "A sample plugin which provides a (fake) label printer interface" AUTHOR = "InvenTree contributors" diff --git a/InvenTree/plugin/samples/integration/report_plugin_sample.py b/InvenTree/plugin/samples/integration/report_plugin_sample.py index c78346b89c..779311432f 100644 --- a/InvenTree/plugin/samples/integration/report_plugin_sample.py +++ b/InvenTree/plugin/samples/integration/report_plugin_sample.py @@ -10,8 +10,8 @@ from report.models import PurchaseOrderReport class SampleReportPlugin(ReportMixin, InvenTreePlugin): """Sample plugin which provides extra context data to a report""" - NAME = "Report Plugin" - SLUG = "reportexample" + NAME = "Sample Report Plugin" + SLUG = "samplereport" TITLE = "Sample Report Plugin" DESCRIPTION = "A sample plugin which provides extra context data to a report" VERSION = "1.0" diff --git a/InvenTree/plugin/samples/integration/sample_currency_exchange.py b/InvenTree/plugin/samples/integration/sample_currency_exchange.py new file mode 100644 index 0000000000..271b98dae9 --- /dev/null +++ b/InvenTree/plugin/samples/integration/sample_currency_exchange.py @@ -0,0 +1,30 @@ +"""Sample plugin for providing dummy currency exchange data""" + +import random + +from django.utils.translation import gettext_lazy as _ + +from plugin import InvenTreePlugin +from plugin.mixins import CurrencyExchangeMixin + + +class SampleCurrencyExchangePlugin(CurrencyExchangeMixin, InvenTreePlugin): + """Dummy currency exchange plugin which provides fake exchange rates""" + + NAME = "Sample Exchange" + DESCRIPTION = _("Sample currency exchange plugin") + SLUG = "samplecurrencyexchange" + VERSION = "0.1.0" + AUTHOR = _("InvenTree Contributors") + + def update_exchange_rates(self, base_currency: str, symbols: list[str]) -> dict: + """Return dummy data for some currencies""" + + rates = { + base_currency: 1.00, + } + + for symbol in symbols: + rates[symbol] = random.randrange(5, 15) * 0.1 + + return rates diff --git a/InvenTree/plugin/samples/integration/version.py b/InvenTree/plugin/samples/integration/version.py index 47314f3df6..d0f2183310 100644 --- a/InvenTree/plugin/samples/integration/version.py +++ b/InvenTree/plugin/samples/integration/version.py @@ -5,5 +5,8 @@ from plugin import InvenTreePlugin class VersionPlugin(InvenTreePlugin): """A small version sample.""" - NAME = "version" - MAX_VERSION = '0.1.0' + SLUG = "sampleversion" + NAME = "Sample Version Plugin" + DESCRIPTION = "A simple plugin which shows how to use the version limits" + MIN_VERSION = '0.1.0' + MAX_VERSION = '1.0.0' diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index eba818cebd..51c6984c09 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -30,7 +30,7 @@ class PluginTagTests(TestCase): """Test that all plugins are listed.""" self.assertEqual(plugin_tags.plugin_list(), registry.plugins) - def test_tag_incative_plugin_list(self): + def test_tag_inactive_plugin_list(self): """Test that all inactive plugins are listed.""" self.assertEqual(plugin_tags.inactive_plugin_list(), registry.plugins_inactive) @@ -48,7 +48,7 @@ class PluginTagTests(TestCase): self.assertEqual(plugin_tags.mixin_enabled(self.sample, key), True) # mixin not enabled self.assertEqual(plugin_tags.mixin_enabled(self.plugin_wrong, key), False) - # mxixn not existing + # mixin not existing self.assertEqual(plugin_tags.mixin_enabled(self.plugin_no, key), False) def test_tag_safe_url(self): @@ -93,7 +93,7 @@ class InvenTreePluginTests(TestCase): class NameInvenTreePlugin(InvenTreePlugin): NAME = 'Aplugin' SLUG = 'a' - TITLE = 'a titel' + TITLE = 'a title' PUBLISH_DATE = "1111-11-11" AUTHOR = 'AA BB' DESCRIPTION = 'A description' @@ -106,6 +106,7 @@ class InvenTreePluginTests(TestCase): class VersionInvenTreePlugin(InvenTreePlugin): NAME = 'Version' + SLUG = 'testversion' MIN_VERSION = '0.1.0' MAX_VERSION = '0.1.3' @@ -133,7 +134,7 @@ class InvenTreePluginTests(TestCase): self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin') self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin') - # is_sampe + # is_sample self.assertEqual(self.plugin.is_sample, False) self.assertEqual(self.plugin_sample.is_sample, True) @@ -145,7 +146,7 @@ class InvenTreePluginTests(TestCase): # human_name self.assertEqual(self.plugin.human_name, '') self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin') - self.assertEqual(self.plugin_name.human_name, 'a titel') + self.assertEqual(self.plugin_name.human_name, 'a title') # description self.assertEqual(self.plugin.description, '') @@ -188,7 +189,7 @@ class InvenTreePluginTests(TestCase): self.assertTrue(self.plugin_version.check_version([0, 1, 0])) self.assertFalse(self.plugin_version.check_version([0, 1, 4])) - plug = registry.plugins_full.get('version') + plug = registry.plugins_full.get('sampleversion') self.assertEqual(plug.is_active(), False) @@ -205,7 +206,7 @@ class RegistryTests(TestCase): # Patch environment variable to add dir envs = {'INVENTREE_PLUGIN_TEST_DIR': directory} with mock.patch.dict(os.environ, envs): - # Reload to redicsover plugins + # Reload to rediscover plugins registry.reload_plugins(full_reload=True, collect=True) # Depends on the meta set in InvenTree/plugin/mock/simple:SimplePlugin @@ -263,12 +264,12 @@ class RegistryTests(TestCase): """Test that the broken samples trigger reloads.""" # In the base setup there are no errors - self.assertEqual(len(registry.errors), 1) + self.assertEqual(len(registry.errors), 0) # Reload the registry with the broken samples dir brokenDir = str(Path(__file__).parent.joinpath('broken').absolute()) with mock.patch.dict(os.environ, {'INVENTREE_PLUGIN_TEST_DIR': brokenDir}): - # Reload to redicsover plugins + # Reload to rediscover plugins registry.reload_plugins(full_reload=True, collect=True) self.assertEqual(len(registry.errors), 3) diff --git a/InvenTree/templates/InvenTree/settings/plugin_settings.html b/InvenTree/templates/InvenTree/settings/plugin_settings.html index 21646a298e..32b0dcaa75 100644 --- a/InvenTree/templates/InvenTree/settings/plugin_settings.html +++ b/InvenTree/templates/InvenTree/settings/plugin_settings.html @@ -101,6 +101,13 @@ {% trans "This is a builtin plugin which cannot be disabled" %} {% else %} + {% if plugin.is_sample %} + + + {% trans "Sample" %} + {% trans "This is a sample plugin" %} + + {% endif %} {% trans "Commit Author" %}{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %} diff --git a/InvenTree/templates/InvenTree/settings/pricing.html b/InvenTree/templates/InvenTree/settings/pricing.html index fbcf9ff9d4..cba117ad14 100644 --- a/InvenTree/templates/InvenTree/settings/pricing.html +++ b/InvenTree/templates/InvenTree/settings/pricing.html @@ -51,6 +51,13 @@ {% endif %} + + + {% include "InvenTree/settings/setting.html" with key="CURRENCY_UPDATE_PLUGIN" icon="fa-cog" %} + {% include "InvenTree/settings/setting.html" with key="CURRENCY_UPDATE_INTERVAL" icon="fa-calendar-alt" %} + +
+
diff --git a/docs/docs/assets/images/settings/currency.png b/docs/docs/assets/images/settings/currency.png new file mode 100644 index 0000000000..71da2ff37a Binary files /dev/null and b/docs/docs/assets/images/settings/currency.png differ diff --git a/docs/docs/extend/plugins.md b/docs/docs/extend/plugins.md index caa67a70a4..beaf60e485 100644 --- a/docs/docs/extend/plugins.md +++ b/docs/docs/extend/plugins.md @@ -95,6 +95,7 @@ Supported mixin classes are: | [APICallMixin](./plugins/api.md) | Perform calls to external APIs | | [AppMixin](./plugins/app.md) | Integrate additional database tables | | [BarcodeMixin](./plugins/barcode.md) | Support custom barcode actions | +| [CurrencyExchangeMixin](./plugins/currency.md) | Custom interfaces for currency exchange rates | | [EventMixin](./plugins/event.md) | Respond to events | | [LabelPrintingMixin](./plugins/label.md) | Custom label printing support | | [LocateMixin](./plugins/locate.md) | Locate and identify stock items | diff --git a/docs/docs/extend/plugins/currency.md b/docs/docs/extend/plugins/currency.md new file mode 100644 index 0000000000..b96178379d --- /dev/null +++ b/docs/docs/extend/plugins/currency.md @@ -0,0 +1,45 @@ +--- +title: Currency Exchange Mixin +--- + +## CurrencyExchangeMixin + +The `CurrencyExchangeMixin` class enabled plugins to provide custom backends for updating currency exchange rate information. + +Any implementing classes must provide the `update_exchange_rates` method. A simple example is shown below (with fake data). + +```python + +from plugin import InvenTreePlugin +from plugin.mixins import CurrencyExchangeMixin + +class MyFirstCurrencyExchangePlugin(CurrencyExchangeMixin, InvenTreePlugin): + """Sample currency exchange plugin""" + + ... + + def update_exchange_rates(self, base_currency: str, symbols: list[str]) -> dict: + """Update currency exchange rates. + + This method *must* be implemented by the plugin class. + + Arguments: + base_currency: The base currency to use for exchange rates + symbols: A list of currency symbols to retrieve exchange rates for + + Returns: + A dictionary of exchange rates, or None if the update failed + + Raises: + Can raise any exception if the update fails + """ + + rates = { + 'base_currency': 1.00 + } + + for sym in symbols: + rates[sym] = random.randrange(5, 15) * 0.1 + + return rates +``` diff --git a/docs/docs/extend/plugins/report.md b/docs/docs/extend/plugins/report.md index 6482c501bc..e72814d8b1 100644 --- a/docs/docs/extend/plugins/report.md +++ b/docs/docs/extend/plugins/report.md @@ -31,7 +31,7 @@ from report.models import PurchaseOrderReport class SampleReportPlugin(ReportMixin, InvenTreePlugin): """Sample plugin which provides extra context data to a report""" - NAME = "Report Plugin" + NAME = "Sample Report Plugin" SLUG = "reportexample" TITLE = "Sample Report Plugin" DESCRIPTION = "A sample plugin which provides extra context data to a report" diff --git a/docs/docs/settings/currency.md b/docs/docs/settings/currency.md new file mode 100644 index 0000000000..6ceb9d0d4b --- /dev/null +++ b/docs/docs/settings/currency.md @@ -0,0 +1,31 @@ +--- +title: Currency Support +--- + +## Currency Support + +InvenTree provides support for multiple currencies, allowing pricing information to be stored with base currency rates. + +### Configuration + +To specify which currencies are supported, refer to the [currency configuration](../start/config.md#supported-currencies) section + +### 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 Rate Updates + +Currency conversion rates are periodically updated, via an external currency exchange server. Out of the box, InvenTree uses the [frankfurter.app](https://www.frankfurter.app/) service, which is an open source currency API made freely available. + +#### Custom Rate Updates + +If a different currency exchange backend is needed, or a custom implementation is desired, the currency exchange framework can be extended [via plugins](../extend/plugins/currency.md). Plugins which implement custom currency exchange frameworks can be easily integrated into the InvenTree framework. + +### Currency Settings + +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 %} diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index 840d9045ce..ec611ab93f 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -164,13 +164,20 @@ The following email settings are available: The "sender" email address is the address from which InvenTree emails are sent (by default) and must be specified for outgoing emails to function: !!! 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) + 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. +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 ## Allowed Hosts / CORS diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 1b6a0525d0..2776a3c780 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -162,6 +162,7 @@ nav: - Error Logs: settings/logs.md - Email: settings/email.md - Background Tasks: settings/tasks.md + - Currency Support: settings/currency.md - App: - InvenTree App: app/app.md - Connect: app/connect.md @@ -202,6 +203,7 @@ nav: - API Mixin: extend/plugins/api.md - App Mixin: extend/plugins/app.md - Barcode Mixin: extend/plugins/barcode.md + - Currency Mixin: extend/plugins/currency.md - Event Mixin: extend/plugins/event.md - Label Printing Mixin: extend/plugins/label.md - Locate Mixin: extend/plugins/locate.md