From c7eb90347a57917c59bfa29c3faa247c3356b003 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 5 Oct 2023 21:19:28 +1100 Subject: [PATCH] Exchange rate plugin (#5667) * Add plugin mixin class for supporting exchange rates * Split some mixin classes out into their own files - mixins.py is becoming quite bloated! * Add some new settings for controlling currency updates * Adds basic plugin implementation * Refactor existing implementation - Builtin plugin uses frankfurter.app API - Better error / edge case handlign * Add sample plugin for currency exchange * Allow user to select which plugin to use for plugin updates * Observe user-configured setting for how often exchange rates are updated * Updates for some of the sample plugins * Fix plugin slug * Add doc page * Document simple example * Improve sample * Add blank page for currency settings info * More info in "config" page * Update docs again * Updated unit tests * Fill out default settings values when InvenTree runs * Add log messages * Significant improvement in default settings speed - Use bulk create - Be efficient - Dont' be inefficient * More strict checks * Refactor default values implementation - Don't run at startup - Run on list API - Implement generic @classmethod --- InvenTree/InvenTree/exchange.py | 123 ++++--- InvenTree/InvenTree/tasks.py | 27 +- InvenTree/common/api.py | 14 +- InvenTree/common/models.py | 60 +++- InvenTree/plugin/admin.py | 2 +- .../plugin/base/integration/APICallMixin.py | 170 +++++++++ .../base/integration/CurrencyExchangeMixin.py | 42 +++ .../base/integration/ValidationMixin.py | 158 +++++++++ InvenTree/plugin/base/integration/mixins.py | 323 +----------------- .../plugin/base/label/test_label_mixin.py | 8 +- .../builtin/integration/currency_exchange.py | 53 +++ InvenTree/plugin/mixins/__init__.py | 9 +- InvenTree/plugin/plugin.py | 6 +- .../samples/integration/label_sample.py | 4 +- .../integration/report_plugin_sample.py | 4 +- .../integration/sample_currency_exchange.py | 30 ++ .../plugin/samples/integration/version.py | 7 +- InvenTree/plugin/test_plugin.py | 19 +- .../InvenTree/settings/plugin_settings.html | 7 + .../templates/InvenTree/settings/pricing.html | 7 + docs/docs/assets/images/settings/currency.png | Bin 0 -> 14776 bytes docs/docs/extend/plugins.md | 1 + docs/docs/extend/plugins/currency.md | 45 +++ docs/docs/extend/plugins/report.md | 2 +- docs/docs/settings/currency.md | 31 ++ docs/docs/start/config.md | 11 +- docs/mkdocs.yml | 2 + 27 files changed, 760 insertions(+), 405 deletions(-) create mode 100644 InvenTree/plugin/base/integration/APICallMixin.py create mode 100644 InvenTree/plugin/base/integration/CurrencyExchangeMixin.py create mode 100644 InvenTree/plugin/base/integration/ValidationMixin.py create mode 100644 InvenTree/plugin/builtin/integration/currency_exchange.py create mode 100644 InvenTree/plugin/samples/integration/sample_currency_exchange.py create mode 100644 docs/docs/assets/images/settings/currency.png create mode 100644 docs/docs/extend/plugins/currency.md create mode 100644 docs/docs/settings/currency.md 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 0000000000000000000000000000000000000000..71da2ff37a537a54f2815992b8919d5599a82aa0 GIT binary patch literal 14776 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 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