mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 12:06:44 +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
|
import logging
|
||||||
from urllib.error import URLError
|
|
||||||
|
|
||||||
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.backends.base import SimpleExchangeBackend
|
||||||
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
|
|
||||||
from common.settings import currency_code_default, currency_codes
|
from common.settings import currency_code_default, currency_codes
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeExchange(SimpleExchangeBackend):
|
class InvenTreeExchange(SimpleExchangeBackend):
|
||||||
"""Backend for automatically updating currency exchange rates.
|
"""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"
|
name = "InvenTreeExchange"
|
||||||
|
|
||||||
def __init__(self):
|
def get_rates(self, **kwargs) -> None:
|
||||||
"""Set API url."""
|
"""Set the requested currency codes and get rates."""
|
||||||
self.url = "https://api.frankfurter.app/latest"
|
|
||||||
|
|
||||||
super().__init__()
|
from common.models import InvenTreeSetting
|
||||||
|
from plugin import registry
|
||||||
|
|
||||||
def get_params(self):
|
base_currency = kwargs.get('base_currency', currency_code_default())
|
||||||
"""Placeholder to set API key. Currently not required by `frankfurter.app`."""
|
symbols = kwargs.get('symbols', currency_codes())
|
||||||
# No API key is required
|
|
||||||
return {
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_response(self, **kwargs):
|
# Find the selected exchange rate plugin
|
||||||
"""Custom code to get response from server.
|
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
|
||||||
|
|
||||||
Note: Adds a 5-second timeout
|
if slug:
|
||||||
"""
|
plugin = registry.get_plugin(slug)
|
||||||
url = self.get_url(**kwargs)
|
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:
|
try:
|
||||||
response = requests.get(url=url, timeout=5)
|
rates = plugin.update_exchange_rates(base_currency, symbols)
|
||||||
return response.content
|
except Exception as exc:
|
||||||
except Exception:
|
logger.exception("Exchange rate update failed: %s", exc)
|
||||||
# Something has gone wrong, but we can just try again next time
|
return {}
|
||||||
# Raise a TypeError so the outer function can handle this
|
|
||||||
raise TypeError
|
|
||||||
|
|
||||||
def get_rates(self, **params):
|
if not rates:
|
||||||
"""Intersect the requested currency codes with the available codes."""
|
logger.warning("Exchange rate update failed - no data returned from plugin %s", slug)
|
||||||
rates = super().get_rates(**params)
|
return {}
|
||||||
|
|
||||||
# Add the base currency to the rates
|
# Update exchange rates based on returned data
|
||||||
rates[params["base_currency"]] = Decimal("1.0")
|
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
|
return rates
|
||||||
|
|
||||||
def update_rates(self, base_currency=None):
|
@atomic
|
||||||
"""Set the requested currency codes and get rates."""
|
def update_rates(self, base_currency=None, **kwargs):
|
||||||
# Set default - see B008
|
"""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:
|
if base_currency is None:
|
||||||
base_currency = currency_code_default()
|
base_currency = currency_code_default()
|
||||||
|
|
||||||
symbols = ','.join(currency_codes())
|
symbols = currency_codes()
|
||||||
|
|
||||||
try:
|
logger.info("Updating exchange rates for %s (%s currencies)", base_currency, len(symbols))
|
||||||
super().update_rates(base=base_currency, symbols=symbols)
|
|
||||||
# catch connection errors
|
# Fetch new rates from the backend
|
||||||
except URLError:
|
# If the backend fails, the existing rates will not be updated
|
||||||
print('Encountered connection error while updating')
|
rates = self.get_rates(base_currency=base_currency, symbols=symbols)
|
||||||
except TypeError:
|
|
||||||
print('Exchange returned invalid response')
|
if rates:
|
||||||
except OperationalError as e:
|
# Clear out existing rates
|
||||||
if 'SerializationFailure' in e.__cause__.__class__.__name__:
|
backend.clear_rates()
|
||||||
print('Serialization Failure while updating exchange rates')
|
|
||||||
# We are just going to swallow this exception because the
|
Rate.objects.bulk_create([
|
||||||
# exchange rates will be updated later by the scheduled task
|
Rate(currency=currency, value=amount, backend=backend)
|
||||||
|
for currency, amount in rates.items()
|
||||||
|
])
|
||||||
else:
|
else:
|
||||||
# Other operational errors probably are still show stoppers
|
logger.info("No exchange rates returned from backend - currencies not updated")
|
||||||
# so reraise them so that the log contains the stacktrace
|
|
||||||
raise
|
logger.info("Updated exchange rates for %s", base_currency)
|
||||||
|
@ -497,19 +497,32 @@ def check_for_updates():
|
|||||||
|
|
||||||
|
|
||||||
@scheduled_task(ScheduledTask.DAILY)
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
def update_exchange_rates():
|
def update_exchange_rates(force: bool = False):
|
||||||
"""Update currency exchange rates."""
|
"""Update currency exchange rates
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
force: If True, force the update to run regardless of the last update time
|
||||||
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from djmoney.contrib.exchange.models import Rate
|
from djmoney.contrib.exchange.models import Rate
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
from common.settings import currency_code_default, currency_codes
|
from common.settings import currency_code_default, currency_codes
|
||||||
from InvenTree.exchange import InvenTreeExchange
|
from InvenTree.exchange import InvenTreeExchange
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||||
return
|
return
|
||||||
except Exception: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
# Other error?
|
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
|
return
|
||||||
|
|
||||||
backend = InvenTreeExchange()
|
backend = InvenTreeExchange()
|
||||||
@ -521,10 +534,14 @@ def update_exchange_rates():
|
|||||||
|
|
||||||
# Remove any exchange rates which are not in the provided currencies
|
# Remove any exchange rates which are not in the provided currencies
|
||||||
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
Rate.objects.filter(backend="InvenTreeExchange").exclude(currency__in=currency_codes()).delete()
|
||||||
|
|
||||||
|
# Record successful task execution
|
||||||
|
record_task_success('update_exchange_rates')
|
||||||
|
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
logger.warning("Could not update exchange rates - database not ready")
|
logger.warning("Could not update exchange rates - database not ready")
|
||||||
except Exception as e: # pragma: no cover
|
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)
|
@scheduled_task(ScheduledTask.DAILY)
|
||||||
|
@ -160,7 +160,7 @@ class CurrencyRefreshView(APIView):
|
|||||||
|
|
||||||
from InvenTree.tasks import update_exchange_rates
|
from InvenTree.tasks import update_exchange_rates
|
||||||
|
|
||||||
update_exchange_rates()
|
update_exchange_rates(force=True)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'success': 'Exchange rates updated',
|
'success': 'Exchange rates updated',
|
||||||
@ -192,6 +192,12 @@ class GlobalSettingsList(SettingsList):
|
|||||||
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
|
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
|
||||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
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):
|
class GlobalSettingsPermissions(permissions.BasePermission):
|
||||||
"""Special permission class to determine if the user is "staff"."""
|
"""Special permission class to determine if the user is "staff"."""
|
||||||
@ -245,6 +251,12 @@ class UserSettingsList(SettingsList):
|
|||||||
queryset = common.models.InvenTreeUserSetting.objects.all()
|
queryset = common.models.InvenTreeUserSetting.objects.all()
|
||||||
serializer_class = common.serializers.UserSettingsSerializer
|
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):
|
def filter_queryset(self, queryset):
|
||||||
"""Only list settings which apply to the current user."""
|
"""Only list settings which apply to the current user."""
|
||||||
try:
|
try:
|
||||||
|
@ -197,6 +197,32 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
# Execute after_save action
|
# Execute after_save action
|
||||||
self._call_settings_function('after_save', args, kwargs)
|
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):
|
def _call_settings_function(self, reference: str, args, kwargs):
|
||||||
"""Call a function associated with a particular setting.
|
"""Call a function associated with a particular setting.
|
||||||
|
|
||||||
@ -939,6 +965,20 @@ def validate_email_domains(setting):
|
|||||||
raise ValidationError(_(f'Invalid domain name: {domain}'))
|
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):
|
def update_exchange_rates(setting):
|
||||||
"""Update exchange rates when base currency is changed"""
|
"""Update exchange rates when base currency is changed"""
|
||||||
|
|
||||||
@ -948,7 +988,7 @@ def update_exchange_rates(setting):
|
|||||||
if not InvenTree.ready.canAppAccessDatabase():
|
if not InvenTree.ready.canAppAccessDatabase():
|
||||||
return
|
return
|
||||||
|
|
||||||
InvenTree.tasks.update_exchange_rates()
|
InvenTree.tasks.update_exchange_rates(force=True)
|
||||||
|
|
||||||
|
|
||||||
def reload_plugin_registry(setting):
|
def reload_plugin_registry(setting):
|
||||||
@ -1053,6 +1093,24 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'after_save': update_exchange_rates,
|
'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': {
|
'INVENTREE_DOWNLOAD_FROM_URL': {
|
||||||
'name': _('Download from URL'),
|
'name': _('Download from URL'),
|
||||||
'description': _('Allow download of remote images and files from external 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."""
|
"""Custom admin with restricted id fields."""
|
||||||
|
|
||||||
readonly_fields = ["key", "name", ]
|
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']
|
list_filter = ['active']
|
||||||
actions = [plugin_activate, plugin_deactivate, ]
|
actions = [plugin_activate, plugin_deactivate, ]
|
||||||
inlines = [PluginSettingInline, ]
|
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."""
|
"""Plugin mixin classes."""
|
||||||
|
|
||||||
import json as json_pkg
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
import part.models
|
|
||||||
import stock.models
|
|
||||||
from InvenTree.helpers import generateTestKey
|
from InvenTree.helpers import generateTestKey
|
||||||
from plugin.helpers import (MixinNotImplementedError, render_template,
|
from plugin.helpers import (MixinNotImplementedError, render_template,
|
||||||
render_text)
|
render_text)
|
||||||
@ -14,159 +9,6 @@ from plugin.helpers import (MixinNotImplementedError, render_template,
|
|||||||
logger = logging.getLogger('inventree')
|
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:
|
class NavigationMixin:
|
||||||
"""Mixin that enables custom navigation links with the plugin."""
|
"""Mixin that enables custom navigation links with the plugin."""
|
||||||
|
|
||||||
@ -181,7 +23,7 @@ class NavigationMixin:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Register mixin."""
|
"""Register mixin."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.add_mixin('navigation', 'has_naviation', __class__)
|
self.add_mixin('navigation', 'has_navigation', __class__)
|
||||||
self.navigation = self.setup_navigation()
|
self.navigation = self.setup_navigation()
|
||||||
|
|
||||||
def setup_navigation(self):
|
def setup_navigation(self):
|
||||||
@ -195,7 +37,7 @@ class NavigationMixin:
|
|||||||
return nav_links
|
return nav_links
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_naviation(self):
|
def has_navigation(self):
|
||||||
"""Does this plugin define navigation elements."""
|
"""Does this plugin define navigation elements."""
|
||||||
return bool(self.navigation)
|
return bool(self.navigation)
|
||||||
|
|
||||||
@ -213,165 +55,6 @@ class NavigationMixin:
|
|||||||
return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question")
|
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:
|
class PanelMixin:
|
||||||
"""Mixin which allows integration of custom 'panels' into a particular page.
|
"""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
|
- icon : The icon to appear in the sidebar menu
|
||||||
- content : The HTML content to appear in the panel, OR
|
- content : The HTML content to appear in the panel, OR
|
||||||
- content_template : A template file which will be rendered to produce the panel content
|
- 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
|
- javascript_template : A template file which will be rendered to produce javascript
|
||||||
|
|
||||||
e.g.
|
e.g.
|
||||||
|
@ -32,7 +32,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def do_activate_plugin(self):
|
def do_activate_plugin(self):
|
||||||
"""Activate the 'samplelabel' plugin."""
|
"""Activate the 'samplelabel' plugin."""
|
||||||
config = registry.get_plugin('samplelabel').plugin_config()
|
config = registry.get_plugin('samplelabelprinter').plugin_config()
|
||||||
config.active = True
|
config.active = True
|
||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
@ -125,7 +125,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(response.data), 2)
|
self.assertEqual(len(response.data), 2)
|
||||||
data = response.data[1]
|
data = response.data[1]
|
||||||
self.assertEqual(data['key'], 'samplelabel')
|
self.assertEqual(data['key'], 'samplelabelprinter')
|
||||||
|
|
||||||
def test_printing_process(self):
|
def test_printing_process(self):
|
||||||
"""Test that a label can be printed."""
|
"""Test that a label can be printed."""
|
||||||
@ -134,7 +134,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
# Lookup references
|
# Lookup references
|
||||||
part = Part.objects.first()
|
part = Part.objects.first()
|
||||||
plugin_ref = 'samplelabel'
|
plugin_ref = 'samplelabelprinter'
|
||||||
label = PartLabel.objects.first()
|
label = PartLabel.objects.first()
|
||||||
|
|
||||||
url = self.do_url([part], plugin_ref, label)
|
url = self.do_url([part], plugin_ref, label)
|
||||||
@ -185,7 +185,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_printing_endpoints(self):
|
def test_printing_endpoints(self):
|
||||||
"""Cover the endpoints not covered by `test_printing_process`."""
|
"""Cover the endpoints not covered by `test_printing_process`."""
|
||||||
plugin_ref = 'samplelabel'
|
plugin_ref = 'samplelabelprinter'
|
||||||
|
|
||||||
# Activate the label components
|
# Activate the label components
|
||||||
apps.get_app_config('label').create_labels()
|
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.action.mixins import ActionMixin
|
||||||
from plugin.base.barcodes.mixins import BarcodeMixin
|
from plugin.base.barcodes.mixins import BarcodeMixin
|
||||||
from plugin.base.event.mixins import EventMixin
|
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.AppMixin import AppMixin
|
||||||
from plugin.base.integration.mixins import (APICallMixin, NavigationMixin,
|
from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin
|
||||||
PanelMixin, SettingsContentMixin,
|
from plugin.base.integration.mixins import (NavigationMixin, PanelMixin,
|
||||||
ValidationMixin)
|
SettingsContentMixin)
|
||||||
from plugin.base.integration.ReportMixin import ReportMixin
|
from plugin.base.integration.ReportMixin import ReportMixin
|
||||||
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
from plugin.base.integration.ScheduleMixin import ScheduleMixin
|
||||||
from plugin.base.integration.SettingsMixin import SettingsMixin
|
from plugin.base.integration.SettingsMixin import SettingsMixin
|
||||||
from plugin.base.integration.UrlsMixin import UrlsMixin
|
from plugin.base.integration.UrlsMixin import UrlsMixin
|
||||||
|
from plugin.base.integration.ValidationMixin import ValidationMixin
|
||||||
from plugin.base.label.mixins import LabelPrintingMixin
|
from plugin.base.label.mixins import LabelPrintingMixin
|
||||||
from plugin.base.locate.mixins import LocateMixin
|
from plugin.base.locate.mixins import LocateMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'APICallMixin',
|
'APICallMixin',
|
||||||
'AppMixin',
|
'AppMixin',
|
||||||
|
'CurrencyExchangeMixin',
|
||||||
'EventMixin',
|
'EventMixin',
|
||||||
'LabelPrintingMixin',
|
'LabelPrintingMixin',
|
||||||
'NavigationMixin',
|
'NavigationMixin',
|
||||||
|
@ -389,7 +389,11 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
|||||||
|
|
||||||
def define_package(self):
|
def define_package(self):
|
||||||
"""Add package info of the plugin into plugins context."""
|
"""Add package info of the plugin into plugins context."""
|
||||||
|
|
||||||
|
try:
|
||||||
package = self._get_package_metadata() if self._is_package else self._get_package_commit()
|
package = self._get_package_metadata() if self._is_package else self._get_package_commit()
|
||||||
|
except TypeError:
|
||||||
|
package = {}
|
||||||
|
|
||||||
# process date
|
# process date
|
||||||
if package.get('date'):
|
if package.get('date'):
|
||||||
|
@ -10,8 +10,8 @@ from plugin.mixins import LabelPrintingMixin
|
|||||||
class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
||||||
"""Sample plugin which provides a 'fake' label printer endpoint."""
|
"""Sample plugin which provides a 'fake' label printer endpoint."""
|
||||||
|
|
||||||
NAME = "Label Printer"
|
NAME = "Sample Label Printer"
|
||||||
SLUG = "samplelabel"
|
SLUG = "samplelabelprinter"
|
||||||
TITLE = "Sample Label Printer"
|
TITLE = "Sample Label Printer"
|
||||||
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
|
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
|
||||||
AUTHOR = "InvenTree contributors"
|
AUTHOR = "InvenTree contributors"
|
||||||
|
@ -10,8 +10,8 @@ from report.models import PurchaseOrderReport
|
|||||||
class SampleReportPlugin(ReportMixin, InvenTreePlugin):
|
class SampleReportPlugin(ReportMixin, InvenTreePlugin):
|
||||||
"""Sample plugin which provides extra context data to a report"""
|
"""Sample plugin which provides extra context data to a report"""
|
||||||
|
|
||||||
NAME = "Report Plugin"
|
NAME = "Sample Report Plugin"
|
||||||
SLUG = "reportexample"
|
SLUG = "samplereport"
|
||||||
TITLE = "Sample Report Plugin"
|
TITLE = "Sample Report Plugin"
|
||||||
DESCRIPTION = "A sample plugin which provides extra context data to a report"
|
DESCRIPTION = "A sample plugin which provides extra context data to a report"
|
||||||
VERSION = "1.0"
|
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):
|
class VersionPlugin(InvenTreePlugin):
|
||||||
"""A small version sample."""
|
"""A small version sample."""
|
||||||
|
|
||||||
NAME = "version"
|
SLUG = "sampleversion"
|
||||||
MAX_VERSION = '0.1.0'
|
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."""
|
"""Test that all plugins are listed."""
|
||||||
self.assertEqual(plugin_tags.plugin_list(), registry.plugins)
|
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."""
|
"""Test that all inactive plugins are listed."""
|
||||||
self.assertEqual(plugin_tags.inactive_plugin_list(), registry.plugins_inactive)
|
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)
|
self.assertEqual(plugin_tags.mixin_enabled(self.sample, key), True)
|
||||||
# mixin not enabled
|
# mixin not enabled
|
||||||
self.assertEqual(plugin_tags.mixin_enabled(self.plugin_wrong, key), False)
|
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)
|
self.assertEqual(plugin_tags.mixin_enabled(self.plugin_no, key), False)
|
||||||
|
|
||||||
def test_tag_safe_url(self):
|
def test_tag_safe_url(self):
|
||||||
@ -93,7 +93,7 @@ class InvenTreePluginTests(TestCase):
|
|||||||
class NameInvenTreePlugin(InvenTreePlugin):
|
class NameInvenTreePlugin(InvenTreePlugin):
|
||||||
NAME = 'Aplugin'
|
NAME = 'Aplugin'
|
||||||
SLUG = 'a'
|
SLUG = 'a'
|
||||||
TITLE = 'a titel'
|
TITLE = 'a title'
|
||||||
PUBLISH_DATE = "1111-11-11"
|
PUBLISH_DATE = "1111-11-11"
|
||||||
AUTHOR = 'AA BB'
|
AUTHOR = 'AA BB'
|
||||||
DESCRIPTION = 'A description'
|
DESCRIPTION = 'A description'
|
||||||
@ -106,6 +106,7 @@ class InvenTreePluginTests(TestCase):
|
|||||||
|
|
||||||
class VersionInvenTreePlugin(InvenTreePlugin):
|
class VersionInvenTreePlugin(InvenTreePlugin):
|
||||||
NAME = 'Version'
|
NAME = 'Version'
|
||||||
|
SLUG = 'testversion'
|
||||||
|
|
||||||
MIN_VERSION = '0.1.0'
|
MIN_VERSION = '0.1.0'
|
||||||
MAX_VERSION = '0.1.3'
|
MAX_VERSION = '0.1.3'
|
||||||
@ -133,7 +134,7 @@ class InvenTreePluginTests(TestCase):
|
|||||||
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
|
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
|
||||||
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
|
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
|
||||||
|
|
||||||
# is_sampe
|
# is_sample
|
||||||
self.assertEqual(self.plugin.is_sample, False)
|
self.assertEqual(self.plugin.is_sample, False)
|
||||||
self.assertEqual(self.plugin_sample.is_sample, True)
|
self.assertEqual(self.plugin_sample.is_sample, True)
|
||||||
|
|
||||||
@ -145,7 +146,7 @@ class InvenTreePluginTests(TestCase):
|
|||||||
# human_name
|
# human_name
|
||||||
self.assertEqual(self.plugin.human_name, '')
|
self.assertEqual(self.plugin.human_name, '')
|
||||||
self.assertEqual(self.plugin_simple.human_name, 'SimplePlugin')
|
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
|
# description
|
||||||
self.assertEqual(self.plugin.description, '')
|
self.assertEqual(self.plugin.description, '')
|
||||||
@ -188,7 +189,7 @@ class InvenTreePluginTests(TestCase):
|
|||||||
self.assertTrue(self.plugin_version.check_version([0, 1, 0]))
|
self.assertTrue(self.plugin_version.check_version([0, 1, 0]))
|
||||||
self.assertFalse(self.plugin_version.check_version([0, 1, 4]))
|
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)
|
self.assertEqual(plug.is_active(), False)
|
||||||
|
|
||||||
|
|
||||||
@ -205,7 +206,7 @@ class RegistryTests(TestCase):
|
|||||||
# Patch environment variable to add dir
|
# Patch environment variable to add dir
|
||||||
envs = {'INVENTREE_PLUGIN_TEST_DIR': directory}
|
envs = {'INVENTREE_PLUGIN_TEST_DIR': directory}
|
||||||
with mock.patch.dict(os.environ, envs):
|
with mock.patch.dict(os.environ, envs):
|
||||||
# Reload to redicsover plugins
|
# Reload to rediscover plugins
|
||||||
registry.reload_plugins(full_reload=True, collect=True)
|
registry.reload_plugins(full_reload=True, collect=True)
|
||||||
|
|
||||||
# Depends on the meta set in InvenTree/plugin/mock/simple:SimplePlugin
|
# 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."""
|
"""Test that the broken samples trigger reloads."""
|
||||||
|
|
||||||
# In the base setup there are no errors
|
# 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
|
# Reload the registry with the broken samples dir
|
||||||
brokenDir = str(Path(__file__).parent.joinpath('broken').absolute())
|
brokenDir = str(Path(__file__).parent.joinpath('broken').absolute())
|
||||||
with mock.patch.dict(os.environ, {'INVENTREE_PLUGIN_TEST_DIR': brokenDir}):
|
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)
|
registry.reload_plugins(full_reload=True, collect=True)
|
||||||
|
|
||||||
self.assertEqual(len(registry.errors), 3)
|
self.assertEqual(len(registry.errors), 3)
|
||||||
|
@ -101,6 +101,13 @@
|
|||||||
<td>{% trans "This is a builtin plugin which cannot be disabled" %}</td>
|
<td>{% trans "This is a builtin plugin which cannot be disabled" %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% 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>
|
<tr>
|
||||||
<td><span class='fas fa-user'></span></td>
|
<td><span class='fas fa-user'></span></td>
|
||||||
<td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td>
|
<td>{% trans "Commit Author" %}</td><td>{{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %}</td>
|
||||||
|
@ -51,6 +51,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
<table class='table table-striped table-condensed' id='exchange-rate-table'></table>
|
||||||
|
|
||||||
</div>
|
</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 |
|
| [APICallMixin](./plugins/api.md) | Perform calls to external APIs |
|
||||||
| [AppMixin](./plugins/app.md) | Integrate additional database tables |
|
| [AppMixin](./plugins/app.md) | Integrate additional database tables |
|
||||||
| [BarcodeMixin](./plugins/barcode.md) | Support custom barcode actions |
|
| [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 |
|
| [EventMixin](./plugins/event.md) | Respond to events |
|
||||||
| [LabelPrintingMixin](./plugins/label.md) | Custom label printing support |
|
| [LabelPrintingMixin](./plugins/label.md) | Custom label printing support |
|
||||||
| [LocateMixin](./plugins/locate.md) | Locate and identify stock items |
|
| [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):
|
class SampleReportPlugin(ReportMixin, InvenTreePlugin):
|
||||||
"""Sample plugin which provides extra context data to a report"""
|
"""Sample plugin which provides extra context data to a report"""
|
||||||
|
|
||||||
NAME = "Report Plugin"
|
NAME = "Sample Report Plugin"
|
||||||
SLUG = "reportexample"
|
SLUG = "reportexample"
|
||||||
TITLE = "Sample Report Plugin"
|
TITLE = "Sample Report Plugin"
|
||||||
DESCRIPTION = "A sample plugin which provides extra context data to a report"
|
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 %}
|
@ -170,7 +170,14 @@ The "sender" email address is the address from which InvenTree emails are sent (
|
|||||||
|
|
||||||
The currencies supported by InvenTree must be specified in the [configuration file](#configuration-file).
|
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
|
## Allowed Hosts / CORS
|
||||||
|
|
||||||
|
@ -162,6 +162,7 @@ nav:
|
|||||||
- Error Logs: settings/logs.md
|
- Error Logs: settings/logs.md
|
||||||
- Email: settings/email.md
|
- Email: settings/email.md
|
||||||
- Background Tasks: settings/tasks.md
|
- Background Tasks: settings/tasks.md
|
||||||
|
- Currency Support: settings/currency.md
|
||||||
- App:
|
- App:
|
||||||
- InvenTree App: app/app.md
|
- InvenTree App: app/app.md
|
||||||
- Connect: app/connect.md
|
- Connect: app/connect.md
|
||||||
@ -202,6 +203,7 @@ nav:
|
|||||||
- API Mixin: extend/plugins/api.md
|
- API Mixin: extend/plugins/api.md
|
||||||
- App Mixin: extend/plugins/app.md
|
- App Mixin: extend/plugins/app.md
|
||||||
- Barcode Mixin: extend/plugins/barcode.md
|
- Barcode Mixin: extend/plugins/barcode.md
|
||||||
|
- Currency Mixin: extend/plugins/currency.md
|
||||||
- Event Mixin: extend/plugins/event.md
|
- Event Mixin: extend/plugins/event.md
|
||||||
- Label Printing Mixin: extend/plugins/label.md
|
- Label Printing Mixin: extend/plugins/label.md
|
||||||
- Locate Mixin: extend/plugins/locate.md
|
- Locate Mixin: extend/plugins/locate.md
|
||||||
|
Loading…
x
Reference in New Issue
Block a user