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
This commit is contained in:
Oliver 2023-10-05 21:19:28 +11:00 committed by GitHub
parent f5e8f27fcd
commit c7eb90347a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 760 additions and 405 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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'),

View File

@ -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, ]

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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.

View File

@ -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()

View File

@ -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'

View File

@ -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',

View File

@ -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'):

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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'

View File

@ -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)

View File

@ -101,6 +101,13 @@
<td>{% trans "This is a builtin plugin which cannot be disabled" %}</td>
</tr>
{% else %}
{% if plugin.is_sample %}
<tr>
<td><span class='fas fa-check-circle icon-blue'></span></td>
<td>{% trans "Sample" %}</td>
<td>{% trans "This is a sample plugin" %}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-user'></span></td>
<td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td>

View File

@ -51,6 +51,13 @@
</div>
{% endif %}
<table class='table table-striped table-condensed'>
<tbody>
{% 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" %}
</tbody>
</table>
<table class='table table-striped table-condensed' id='exchange-rate-table'></table>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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 |

View File

@ -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
```

View File

@ -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"

View File

@ -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 %}

View File

@ -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

View File

@ -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