mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
f5e8f27fcd
commit
c7eb90347a
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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'),
|
||||
|
@ -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, ]
|
||||
|
170
InvenTree/plugin/base/integration/APICallMixin.py
Normal file
170
InvenTree/plugin/base/integration/APICallMixin.py
Normal 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
|
42
InvenTree/plugin/base/integration/CurrencyExchangeMixin.py
Normal file
42
InvenTree/plugin/base/integration/CurrencyExchangeMixin.py
Normal 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")
|
158
InvenTree/plugin/base/integration/ValidationMixin.py
Normal file
158
InvenTree/plugin/base/integration/ValidationMixin.py
Normal 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
|
@ -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.
|
||||
|
@ -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()
|
||||
|
53
InvenTree/plugin/builtin/integration/currency_exchange.py
Normal file
53
InvenTree/plugin/builtin/integration/currency_exchange.py
Normal 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'
|
@ -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',
|
||||
|
@ -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'):
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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
|
@ -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'
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
BIN
docs/docs/assets/images/settings/currency.png
Normal file
BIN
docs/docs/assets/images/settings/currency.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
@ -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 |
|
||||
|
45
docs/docs/extend/plugins/currency.md
Normal file
45
docs/docs/extend/plugins/currency.md
Normal 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
|
||||
```
|
@ -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"
|
||||
|
31
docs/docs/settings/currency.md
Normal file
31
docs/docs/settings/currency.md
Normal 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 %}
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user