2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-04 06:18:48 +00:00

Merge pull request #2492 from SchrodingersGat/plugin-settings-refactor

Plugin settings refactor
This commit is contained in:
Oliver 2022-01-07 14:12:22 +11:00 committed by GitHub
commit 7967c0c0bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 649 additions and 320 deletions

View File

@ -2,9 +2,14 @@
Custom management command to cleanup old settings that are not defined anymore Custom management command to cleanup old settings that are not defined anymore
""" """
import logging
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
logger = logging.getLogger('inventree')
class Command(BaseCommand): class Command(BaseCommand):
""" """
Cleanup old (undefined) settings in the database Cleanup old (undefined) settings in the database
@ -12,27 +17,27 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
print("Collecting settings") logger.info("Collecting settings")
from common.models import InvenTreeSetting, InvenTreeUserSetting from common.models import InvenTreeSetting, InvenTreeUserSetting
# general settings # general settings
db_settings = InvenTreeSetting.objects.all() db_settings = InvenTreeSetting.objects.all()
model_settings = InvenTreeSetting.GLOBAL_SETTINGS model_settings = InvenTreeSetting.SETTINGS
# check if key exist and delete if not # check if key exist and delete if not
for setting in db_settings: for setting in db_settings:
if setting.key not in model_settings: if setting.key not in model_settings:
setting.delete() setting.delete()
print(f"deleted setting '{setting.key}'") logger.info(f"deleted setting '{setting.key}'")
# user settings # user settings
db_settings = InvenTreeUserSetting.objects.all() db_settings = InvenTreeUserSetting.objects.all()
model_settings = InvenTreeUserSetting.GLOBAL_SETTINGS model_settings = InvenTreeUserSetting.SETTINGS
# check if key exist and delete if not # check if key exist and delete if not
for setting in db_settings: for setting in db_settings:
if setting.key not in model_settings: if setting.key not in model_settings:
setting.delete() setting.delete()
print(f"deleted user setting '{setting.key}'") logger.info(f"deleted user setting '{setting.key}'")
print("checked all settings") logger.info("checked all settings")

View File

@ -12,11 +12,15 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 22 INVENTREE_API_VERSION = 23
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v23 -> 2022-02-02
- Adds API endpoints for managing plugin classes
- Adds API endpoints for managing plugin settings
v22 -> 2021-12-20 v22 -> 2021-12-20
- Adds API endpoint to "merge" multiple stock items - Adds API endpoint to "merge" multiple stock items

View File

@ -53,7 +53,7 @@ class BaseInvenTreeSetting(models.Model):
single values (e.g. one-off settings values). single values (e.g. one-off settings values).
""" """
GLOBAL_SETTINGS = {} SETTINGS = {}
class Meta: class Meta:
abstract = True abstract = True
@ -65,7 +65,7 @@ class BaseInvenTreeSetting(models.Model):
self.key = str(self.key).upper() self.key = str(self.key).upper()
self.clean() self.clean(**kwargs)
self.validate_unique() self.validate_unique()
super().save() super().save()
@ -82,6 +82,7 @@ class BaseInvenTreeSetting(models.Model):
results = cls.objects.all() results = cls.objects.all()
# Optionally filter by user
if user is not None: if user is not None:
results = results.filter(user=user) results = results.filter(user=user)
@ -93,13 +94,13 @@ class BaseInvenTreeSetting(models.Model):
settings[setting.key.upper()] = setting.value settings[setting.key.upper()] = setting.value
# Specify any "default" values which are not in the database # Specify any "default" values which are not in the database
for key in cls.GLOBAL_SETTINGS.keys(): for key in cls.SETTINGS.keys():
if key.upper() not in settings: if key.upper() not in settings:
settings[key.upper()] = cls.get_setting_default(key) settings[key.upper()] = cls.get_setting_default(key)
if exclude_hidden: if exclude_hidden:
hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False) hidden = cls.SETTINGS[key].get('hidden', False)
if hidden: if hidden:
# Remove hidden items # Remove hidden items
@ -123,98 +124,92 @@ class BaseInvenTreeSetting(models.Model):
return settings return settings
@classmethod @classmethod
def get_setting_name(cls, key): def get_setting_definition(cls, key, **kwargs):
"""
Return the 'definition' of a particular settings value, as a dict object.
- The 'settings' dict can be passed as a kwarg
- If not passed, look for cls.SETTINGS
- Returns an empty dict if the key is not found
"""
settings = kwargs.get('settings', cls.SETTINGS)
key = str(key).strip().upper()
if settings is not None and key in settings:
return settings[key]
else:
return {}
@classmethod
def get_setting_name(cls, key, **kwargs):
""" """
Return the name of a particular setting. Return the name of a particular setting.
If it does not exist, return an empty string. If it does not exist, return an empty string.
""" """
key = str(key).strip().upper() setting = cls.get_setting_definition(key, **kwargs)
return setting.get('name', '')
if key in cls.GLOBAL_SETTINGS:
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('name', '')
else:
return ''
@classmethod @classmethod
def get_setting_description(cls, key): def get_setting_description(cls, key, **kwargs):
""" """
Return the description for a particular setting. Return the description for a particular setting.
If it does not exist, return an empty string. If it does not exist, return an empty string.
""" """
key = str(key).strip().upper() setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS: return setting.get('description', '')
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('description', '')
else:
return ''
@classmethod @classmethod
def get_setting_units(cls, key): def get_setting_units(cls, key, **kwargs):
""" """
Return the units for a particular setting. Return the units for a particular setting.
If it does not exist, return an empty string. If it does not exist, return an empty string.
""" """
key = str(key).strip().upper() setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS: return setting.get('units', '')
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('units', '')
else:
return ''
@classmethod @classmethod
def get_setting_validator(cls, key): def get_setting_validator(cls, key, **kwargs):
""" """
Return the validator for a particular setting. Return the validator for a particular setting.
If it does not exist, return None If it does not exist, return None
""" """
key = str(key).strip().upper() setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS: return setting.get('validator', None)
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('validator', None)
else:
return None
@classmethod @classmethod
def get_setting_default(cls, key): def get_setting_default(cls, key, **kwargs):
""" """
Return the default value for a particular setting. Return the default value for a particular setting.
If it does not exist, return an empty string If it does not exist, return an empty string
""" """
key = str(key).strip().upper() setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS: return setting.get('default', '')
setting = cls.GLOBAL_SETTINGS[key]
return setting.get('default', '')
else:
return ''
@classmethod @classmethod
def get_setting_choices(cls, key): def get_setting_choices(cls, key, **kwargs):
""" """
Return the validator choices available for a particular setting. Return the validator choices available for a particular setting.
""" """
key = str(key).strip().upper() setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS: choices = setting.get('choices', None)
setting = cls.GLOBAL_SETTINGS[key]
choices = setting.get('choices', None)
else:
choices = None
if callable(choices): if callable(choices):
# Evaluate the function (we expect it will return a list of tuples...) # Evaluate the function (we expect it will return a list of tuples...)
@ -237,17 +232,40 @@ class BaseInvenTreeSetting(models.Model):
key = str(key).strip().upper() key = str(key).strip().upper()
settings = cls.objects.all()
# Filter by user
user = kwargs.get('user', None)
if user is not None:
settings = settings.filter(user=user)
try: try:
setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first() setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
except (ValueError, cls.DoesNotExist): except (ValueError, cls.DoesNotExist):
setting = None setting = None
except (IntegrityError, OperationalError): except (IntegrityError, OperationalError):
setting = None setting = None
plugin = kwargs.pop('plugin', None)
if plugin:
from plugin import InvenTreePlugin
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
kwargs['plugin'] = plugin
# Setting does not exist! (Try to create it) # Setting does not exist! (Try to create it)
if not setting: if not setting:
setting = cls(key=key, value=cls.get_setting_default(key), **kwargs) # Attempt to create a new settings object
setting = cls(
key=key,
value=cls.get_setting_default(key, **kwargs),
**kwargs
)
try: try:
# Wrap this statement in "atomic", so it can be rolled back if it fails # Wrap this statement in "atomic", so it can be rolled back if it fails
@ -259,21 +277,6 @@ class BaseInvenTreeSetting(models.Model):
return setting return setting
@classmethod
def get_setting_pk(cls, key):
"""
Return the primary-key value for a given setting.
If the setting does not exist, return None
"""
setting = cls.get_setting_object(cls)
if setting:
return setting.pk
else:
return None
@classmethod @classmethod
def get_setting(cls, key, backup_value=None, **kwargs): def get_setting(cls, key, backup_value=None, **kwargs):
""" """
@ -283,18 +286,19 @@ class BaseInvenTreeSetting(models.Model):
# If no backup value is specified, atttempt to retrieve a "default" value # If no backup value is specified, atttempt to retrieve a "default" value
if backup_value is None: if backup_value is None:
backup_value = cls.get_setting_default(key) backup_value = cls.get_setting_default(key, **kwargs)
setting = cls.get_setting_object(key, **kwargs) setting = cls.get_setting_object(key, **kwargs)
if setting: if setting:
value = setting.value value = setting.value
# If the particular setting is defined as a boolean, cast the value to a boolean # Cast to boolean if necessary
if setting.is_bool(): if setting.is_bool(**kwargs):
value = InvenTree.helpers.str2bool(value) value = InvenTree.helpers.str2bool(value)
if setting.is_int(): # Cast to integer if necessary
if setting.is_int(**kwargs):
try: try:
value = int(value) value = int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
@ -357,7 +361,7 @@ class BaseInvenTreeSetting(models.Model):
def units(self): def units(self):
return self.__class__.get_setting_units(self.key) return self.__class__.get_setting_units(self.key)
def clean(self): def clean(self, **kwargs):
""" """
If a validator (or multiple validators) are defined for a particular setting key, If a validator (or multiple validators) are defined for a particular setting key,
run them against the 'value' field. run them against the 'value' field.
@ -365,25 +369,16 @@ class BaseInvenTreeSetting(models.Model):
super().clean() super().clean()
validator = self.__class__.get_setting_validator(self.key) validator = self.__class__.get_setting_validator(self.key, **kwargs)
if self.is_bool(): if validator is not None:
self.value = InvenTree.helpers.str2bool(self.value) self.run_validator(validator)
if self.is_int():
try:
self.value = int(self.value)
except (ValueError):
raise ValidationError(_('Must be an integer value'))
options = self.valid_options() options = self.valid_options()
if options and self.value not in options: if options and self.value not in options:
raise ValidationError(_("Chosen value is not a valid option")) raise ValidationError(_("Chosen value is not a valid option"))
if validator is not None:
self.run_validator(validator)
def run_validator(self, validator): def run_validator(self, validator):
""" """
Run a validator against the 'value' field for this InvenTreeSetting object. Run a validator against the 'value' field for this InvenTreeSetting object.
@ -395,7 +390,7 @@ class BaseInvenTreeSetting(models.Model):
value = self.value value = self.value
# Boolean validator # Boolean validator
if self.is_bool(): if validator is bool:
# Value must "look like" a boolean value # Value must "look like" a boolean value
if InvenTree.helpers.is_bool(value): if InvenTree.helpers.is_bool(value):
# Coerce into either "True" or "False" # Coerce into either "True" or "False"
@ -406,7 +401,7 @@ class BaseInvenTreeSetting(models.Model):
}) })
# Integer validator # Integer validator
if self.is_int(): if validator is int:
try: try:
# Coerce into an integer value # Coerce into an integer value
@ -459,12 +454,12 @@ class BaseInvenTreeSetting(models.Model):
return [opt[0] for opt in choices] return [opt[0] for opt in choices]
def is_bool(self): def is_bool(self, **kwargs):
""" """
Check if this setting is required to be a boolean value Check if this setting is required to be a boolean value
""" """
validator = self.__class__.get_setting_validator(self.key) validator = self.__class__.get_setting_validator(self.key, **kwargs)
return self.__class__.validator_is_bool(validator) return self.__class__.validator_is_bool(validator)
@ -477,15 +472,15 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value) return InvenTree.helpers.str2bool(self.value)
def setting_type(self): def setting_type(self, **kwargs):
""" """
Return the field type identifier for this setting object Return the field type identifier for this setting object
""" """
if self.is_bool(): if self.is_bool(**kwargs):
return 'boolean' return 'boolean'
elif self.is_int(): elif self.is_int(**kwargs):
return 'integer' return 'integer'
else: else:
@ -504,12 +499,12 @@ class BaseInvenTreeSetting(models.Model):
return False return False
def is_int(self): def is_int(self, **kwargs):
""" """
Check if the setting is required to be an integer value: Check if the setting is required to be an integer value:
""" """
validator = self.__class__.get_setting_validator(self.key) validator = self.__class__.get_setting_validator(self.key, **kwargs)
return self.__class__.validator_is_int(validator) return self.__class__.validator_is_int(validator)
@ -541,21 +536,20 @@ class BaseInvenTreeSetting(models.Model):
return value return value
@classmethod @classmethod
def is_protected(cls, key): def is_protected(cls, key, **kwargs):
""" """
Check if the setting value is protected Check if the setting value is protected
""" """
key = str(key).strip().upper() setting = cls.get_setting_definition(key, **kwargs)
if key in cls.GLOBAL_SETTINGS: return setting.get('protected', False)
return cls.GLOBAL_SETTINGS[key].get('protected', False)
else:
return False
def settings_group_options(): def settings_group_options():
"""build up group tuple for settings based on gour choices""" """
Build up group tuple for settings based on your choices
"""
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]] return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
@ -595,7 +589,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
The keys must be upper-case The keys must be upper-case
""" """
GLOBAL_SETTINGS = { SETTINGS = {
'SERVER_RESTART_REQUIRED': { 'SERVER_RESTART_REQUIRED': {
'name': _('Restart required'), 'name': _('Restart required'),
@ -977,13 +971,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
'requires_restart': True, 'requires_restart': True,
}, },
'ENABLE_PLUGINS_GLOBALSETTING': {
'name': _('Enable global setting integration'),
'description': _('Enable plugins to integrate into inventree global settings'),
'default': False,
'validator': bool,
'requires_restart': True,
},
'ENABLE_PLUGINS_APP': { 'ENABLE_PLUGINS_APP': {
'name': _('Enable app integration'), 'name': _('Enable app integration'),
'description': _('Enable plugins to add apps'), 'description': _('Enable plugins to add apps'),
@ -1017,7 +1004,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
Return True if this setting requires a server restart after changing Return True if this setting requires a server restart after changing
""" """
options = InvenTreeSetting.GLOBAL_SETTINGS.get(self.key, None) options = InvenTreeSetting.SETTINGS.get(self.key, None)
if options: if options:
return options.get('requires_restart', False) return options.get('requires_restart', False)
@ -1030,7 +1017,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
An InvenTreeSetting object with a usercontext An InvenTreeSetting object with a usercontext
""" """
GLOBAL_SETTINGS = { SETTINGS = {
'HOMEPAGE_PART_STARRED': { 'HOMEPAGE_PART_STARRED': {
'name': _('Show subscribed parts'), 'name': _('Show subscribed parts'),
'description': _('Show subscribed parts on the homepage'), 'description': _('Show subscribed parts on the homepage'),

View File

@ -49,9 +49,9 @@ class SettingsTest(TestCase):
- Ensure that every global setting has a description. - Ensure that every global setting has a description.
""" """
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys(): for key in InvenTreeSetting.SETTINGS.keys():
setting = InvenTreeSetting.GLOBAL_SETTINGS[key] setting = InvenTreeSetting.SETTINGS[key]
name = setting.get('name', None) name = setting.get('name', None)
@ -64,14 +64,14 @@ class SettingsTest(TestCase):
raise ValueError(f'Missing GLOBAL_SETTING description for {key}') raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
if not key == key.upper(): if not key == key.upper():
raise ValueError(f"GLOBAL_SETTINGS key '{key}' is not uppercase") raise ValueError(f"SETTINGS key '{key}' is not uppercase")
def test_defaults(self): def test_defaults(self):
""" """
Populate the settings with default values Populate the settings with default values
""" """
for key in InvenTreeSetting.GLOBAL_SETTINGS.keys(): for key in InvenTreeSetting.SETTINGS.keys():
value = InvenTreeSetting.get_setting_default(key) value = InvenTreeSetting.get_setting_default(key)

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" This module provides template tags for extra functionality """
This module provides template tags for extra functionality,
over and above the built-in Django tags. over and above the built-in Django tags.
""" """
@ -22,6 +23,8 @@ import InvenTree.helpers
from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
from common.settings import currency_code_default from common.settings import currency_code_default
from plugin.models import PluginSetting
register = template.Library() register = template.Library()
@ -223,8 +226,16 @@ def setting_object(key, *args, **kwargs):
if a user-setting was requested return that if a user-setting was requested return that
""" """
if 'plugin' in kwargs:
# Note, 'plugin' is an instance of an InvenTreePlugin class
plugin = kwargs['plugin']
return PluginSetting.get_setting_object(key, plugin=plugin)
if 'user' in kwargs: if 'user' in kwargs:
return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user']) return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
return InvenTreeSetting.get_setting_object(key) return InvenTreeSetting.get_setting_object(key)

View File

@ -1,7 +1,11 @@
from .registry import plugins as plugin_reg from .registry import plugin_registry
from .plugin import InvenTreePlugin
from .integration import IntegrationPluginBase from .integration import IntegrationPluginBase
from .action import ActionPlugin from .action import ActionPlugin
__all__ = [ __all__ = [
'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin', 'ActionPlugin',
'IntegrationPluginBase',
'InvenTreePlugin',
'plugin_registry',
] ]

View File

@ -4,43 +4,70 @@ from __future__ import unicode_literals
from django.contrib import admin from django.contrib import admin
import plugin.models as models import plugin.models as models
from plugin import plugin_reg import plugin.registry as registry
def plugin_update(queryset, new_status: bool): def plugin_update(queryset, new_status: bool):
"""general function for bulk changing plugins""" """
General function for bulk changing plugins
"""
apps_changed = False apps_changed = False
# run through all plugins in the queryset as the save method needs to be overridden # Run through all plugins in the queryset as the save method needs to be overridden
for plugin in queryset: for plugin in queryset:
if plugin.active is not new_status: if plugin.active is not new_status:
plugin.active = new_status plugin.active = new_status
plugin.save(no_reload=True) plugin.save(no_reload=True)
apps_changed = True apps_changed = True
# reload plugins if they changed # Reload plugins if they changed
if apps_changed: if apps_changed:
plugin_reg.reload_plugins() registry.plugin_registry.reload_plugins()
@admin.action(description='Activate plugin(s)') @admin.action(description='Activate plugin(s)')
def plugin_activate(modeladmin, request, queryset): def plugin_activate(modeladmin, request, queryset):
"""activate a set of plugins""" """
Activate a set of plugins
"""
plugin_update(queryset, True) plugin_update(queryset, True)
@admin.action(description='Deactivate plugin(s)') @admin.action(description='Deactivate plugin(s)')
def plugin_deactivate(modeladmin, request, queryset): def plugin_deactivate(modeladmin, request, queryset):
"""deactivate a set of plugins""" """
Deactivate a set of plugins
"""
plugin_update(queryset, False) plugin_update(queryset, False)
class PluginSettingInline(admin.TabularInline):
"""
Inline admin class for PluginSetting
"""
model = models.PluginSetting
read_only_fields = [
'key',
]
def has_add_permission(self, request, obj):
return False
class PluginConfigAdmin(admin.ModelAdmin): 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 = ['active', '__str__', 'key', 'name', ] list_display = ['name', 'key', '__str__', 'active', ]
list_filter = ['active'] list_filter = ['active']
actions = [plugin_activate, plugin_deactivate, ] actions = [plugin_activate, plugin_deactivate, ]
inlines = [PluginSettingInline, ]
admin.site.register(models.PluginConfig, PluginConfigAdmin) admin.site.register(models.PluginConfig, PluginConfigAdmin)

View File

@ -11,7 +11,8 @@ from rest_framework import generics
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plugin.models import PluginConfig from common.api import GlobalSettingsPermissions
from plugin.models import PluginConfig, PluginSetting
import plugin.serializers as PluginSerializers import plugin.serializers as PluginSerializers
@ -76,7 +77,46 @@ class PluginInstall(generics.CreateAPIView):
return serializer.save() return serializer.save()
class PluginSettingList(generics.ListAPIView):
"""
List endpoint for all plugin related settings.
- read only
- only accessible by staff users
"""
queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [
GlobalSettingsPermissions,
]
class PluginSettingDetail(generics.RetrieveUpdateAPIView):
"""
Detail endpoint for a plugin-specific setting.
Note that these cannot be created or deleted via the API
"""
queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer
# Staff permission required
permission_classes = [
GlobalSettingsPermissions,
]
plugin_api_urls = [ plugin_api_urls = [
# Plugin settings URLs
url(r'^settings/', include([
url(r'^(?P<pk>\d+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),
url(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'),
])),
# Detail views for a single PluginConfig item # Detail views for a single PluginConfig item
url(r'^(?P<pk>\d+)/', include([ url(r'^(?P<pk>\d+)/', include([
url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),

View File

@ -4,17 +4,17 @@ from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig
from maintenance_mode.core import set_maintenance_mode from maintenance_mode.core import set_maintenance_mode
from plugin.registry import plugins from plugin import plugin_registry
class PluginAppConfig(AppConfig): class PluginAppConfig(AppConfig):
name = 'plugin' name = 'plugin'
def ready(self): def ready(self):
if not plugins.is_loading: if not plugin_registry.is_loading:
# this is the first startup # this is the first startup
plugins.collect_plugins() plugin_registry.collect_plugins()
plugins.load_plugins() plugin_registry.load_plugins()
# drop out of maintenance # drop out of maintenance
# makes sure we did not have an error in reloading and maintenance is still active # makes sure we did not have an error in reloading and maintenance is still active

View File

@ -3,7 +3,9 @@ Plugin mixin classes
""" """
from django.conf.urls import url, include from django.conf.urls import url, include
from django.db.utils import OperationalError, ProgrammingError
from plugin.models import PluginConfig, PluginSetting
from plugin.urls import PLUGIN_BASE from plugin.urls import PLUGIN_BASE
@ -17,44 +19,38 @@ class SettingsMixin:
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.add_mixin('settings', 'has_globalsettings', __class__) self.add_mixin('settings', 'has_settings', __class__)
self.globalsettings = getattr(self, 'SETTINGS', None) self.settings = getattr(self, 'SETTINGS', {})
@property @property
def has_globalsettings(self): def has_settings(self):
""" """
Does this plugin use custom global settings Does this plugin use custom global settings
""" """
return bool(self.globalsettings) return bool(self.settings)
@property def get_setting(self, key):
def globalsettingspatterns(self):
""" """
Get patterns for InvenTreeSetting defintion Return the 'value' of the setting associated with this plugin
""" """
if self.has_globalsettings:
return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()}
return None
def _globalsetting_name(self, key): return PluginSetting.get_setting(key, plugin=self)
"""
Get global name of setting
"""
return f'PLUGIN_{self.slug.upper()}_{key}'
def get_globalsetting(self, key): def set_setting(self, key, value, user=None):
""" """
get plugin global setting by key Set plugin setting value by key
""" """
from common.models import InvenTreeSetting
return InvenTreeSetting.get_setting(self._globalsetting_name(key))
def set_globalsetting(self, key, value, user): try:
""" plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
set plugin global setting by key except (OperationalError, ProgrammingError):
""" plugin = None
from common.models import InvenTreeSetting
return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user) if not plugin:
# Cannot find associated plugin model, return
return
PluginSetting.set_setting(key, value, user, plugin=plugin)
class UrlsMixin: class UrlsMixin:

View File

@ -10,14 +10,14 @@ from django.conf import settings
# region logging / errors # region logging / errors
def log_plugin_error(error, reference: str = 'general'): def log_plugin_error(error, reference: str = 'general'):
from plugin import plugin_reg from plugin import plugin_registry
# make sure the registry is set up # make sure the registry is set up
if reference not in plugin_reg.errors: if reference not in plugin_registry.errors:
plugin_reg.errors[reference] = [] plugin_registry.errors[reference] = []
# add error to stack # add error to stack
plugin_reg.errors[reference].append(error) plugin_registry.errors[reference].append(error)
class IntegrationPluginError(Exception): class IntegrationPluginError(Exception):

View File

@ -9,7 +9,6 @@ import pathlib
from django.urls.base import reverse from django.urls.base import reverse
from django.conf import settings from django.conf import settings
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
import plugin.plugin as plugin import plugin.plugin as plugin
@ -20,19 +19,27 @@ logger = logging.getLogger("inventree")
class MixinBase: class MixinBase:
"""general base for mixins""" """
General base for mixins
"""
def __init__(self) -> None: def __init__(self) -> None:
self._mixinreg = {} self._mixinreg = {}
self._mixins = {} self._mixins = {}
def add_mixin(self, key: str, fnc_enabled=True, cls=None): def add_mixin(self, key: str, fnc_enabled=True, cls=None):
"""add a mixin to the plugins registry""" """
Add a mixin to the plugins registry
"""
self._mixins[key] = fnc_enabled self._mixins[key] = fnc_enabled
self.setup_mixin(key, cls=cls) self.setup_mixin(key, cls=cls)
def setup_mixin(self, key, cls=None): def setup_mixin(self, key, cls=None):
"""define mixin details for the current mixin -> provides meta details for all active mixins""" """
Define mixin details for the current mixin -> provides meta details for all active mixins
"""
# get human name # get human name
human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
@ -44,7 +51,10 @@ class MixinBase:
@property @property
def registered_mixins(self, with_base: bool = False): def registered_mixins(self, with_base: bool = False):
"""get all registered mixins for the plugin""" """
Get all registered mixins for the plugin
"""
mixins = getattr(self, '_mixinreg', None) mixins = getattr(self, '_mixinreg', None)
if mixins: if mixins:
# filter out base # filter out base
@ -59,8 +69,6 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
""" """
The IntegrationPluginBase class is used to integrate with 3rd party software The IntegrationPluginBase class is used to integrate with 3rd party software
""" """
PLUGIN_SLUG = None
PLUGIN_TITLE = None
AUTHOR = None AUTHOR = None
DESCRIPTION = None DESCRIPTION = None
@ -84,11 +92,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
# region properties # region properties
@property @property
def slug(self): def slug(self):
"""slug for the plugin""" return self.plugin_slug()
slug = getattr(self, 'PLUGIN_SLUG', None)
if not slug:
slug = self.plugin_name()
return slugify(slug)
@property @property
def human_name(self): def human_name(self):

View File

@ -4,7 +4,7 @@ load templates for loaded plugins
from django.template.loaders.filesystem import Loader as FilesystemLoader from django.template.loaders.filesystem import Loader as FilesystemLoader
from pathlib import Path from pathlib import Path
from plugin import plugin_reg from plugin import plugin_registry
class PluginTemplateLoader(FilesystemLoader): class PluginTemplateLoader(FilesystemLoader):
@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
def get_dirs(self): def get_dirs(self):
dirname = 'templates' dirname = 'templates'
template_dirs = [] template_dirs = []
for plugin in plugin_reg.plugins.values(): for plugin in plugin_registry.plugins.values():
new_path = Path(plugin.path) / dirname new_path = Path(plugin.path) / dirname
if Path(new_path).is_dir(): if Path(new_path).is_dir():
template_dirs.append(new_path) template_dirs.append(new_path)

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.10 on 2022-01-01 10:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('plugin', '0002_alter_pluginconfig_options'),
]
operations = [
migrations.CreateModel(
name='PluginSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(help_text='Settings key (must be unique - case insensitive', max_length=50)),
('value', models.CharField(blank=True, help_text='Settings value', max_length=200)),
('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='plugin.pluginconfig', verbose_name='Plugin')),
],
options={
'unique_together': {('plugin', 'key')},
},
),
]

View File

@ -8,16 +8,17 @@ from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db import models from django.db import models
from plugin import plugin_reg import common.models
from plugin import InvenTreePlugin, plugin_registry
class PluginConfig(models.Model): class PluginConfig(models.Model):
""" A PluginConfig object holds settings for plugins. """
A PluginConfig object holds settings for plugins.
It is used to designate a Part as 'subscribed' for a given User.
Attributes: Attributes:
key: slug of the plugin - must be unique key: slug of the plugin (this must be unique across all installed plugins!)
name: PluginName of the plugin - serves for a manual double check if the right plugin is used name: PluginName of the plugin - serves for a manual double check if the right plugin is used
active: Should the plugin be loaded? active: Should the plugin be loaded?
""" """
@ -63,12 +64,15 @@ class PluginConfig(models.Model):
# functions # functions
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""override to set original state of""" """
Override to set original state of the plugin-config instance
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.__org_active = self.active self.__org_active = self.active
# append settings from registry # append settings from registry
self.plugin = plugin_reg.plugins.get(self.key, None) self.plugin = plugin_registry.plugins.get(self.key, None)
def get_plugin_meta(name): def get_plugin_meta(name):
if self.plugin: if self.plugin:
@ -82,16 +86,112 @@ class PluginConfig(models.Model):
} }
def save(self, force_insert=False, force_update=False, *args, **kwargs): def save(self, force_insert=False, force_update=False, *args, **kwargs):
"""extend save method to reload plugins if the 'active' status changes""" """
Extend save method to reload plugins if the 'active' status changes
"""
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
ret = super().save(force_insert, force_update, *args, **kwargs) ret = super().save(force_insert, force_update, *args, **kwargs)
if not reload: if not reload:
if self.active is False and self.__org_active is True: if self.active is False and self.__org_active is True:
plugin_reg.reload_plugins() plugin_registry.reload_plugins()
elif self.active is True and self.__org_active is False: elif self.active is True and self.__org_active is False:
plugin_reg.reload_plugins() plugin_registry.reload_plugins()
return ret return ret
class PluginSetting(common.models.BaseInvenTreeSetting):
"""
This model represents settings for individual plugins
"""
class Meta:
unique_together = [
('plugin', 'key'),
]
def clean(self, **kwargs):
kwargs['plugin'] = self.plugin
super().clean(**kwargs)
"""
We override the following class methods,
so that we can pass the plugin instance
"""
@property
def name(self):
return self.__class__.get_setting_name(self.key, plugin=self.plugin)
@property
def default_value(self):
return self.__class__.get_setting_default(self.key, plugin=self.plugin)
@property
def description(self):
return self.__class__.get_setting_description(self.key, plugin=self.plugin)
@property
def units(self):
return self.__class__.get_setting_units(self.key, plugin=self.plugin)
def choices(self):
return self.__class__.get_setting_choices(self.key, plugin=self.plugin)
@classmethod
def get_setting_definition(cls, key, **kwargs):
"""
In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS',
which is a dict object that fully defines all the setting parameters.
Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings
'ahead of time' (as they are defined externally in the plugins).
Settings can be provided by the caller, as kwargs['settings'].
If not provided, we'll look at the plugin registry to see what settings are available,
(if the plugin is specified!)
"""
if 'settings' not in kwargs:
plugin = kwargs.pop('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
kwargs['settings'] = plugin_registry.mixins_settings.get(plugin.key, {})
return super().get_setting_definition(key, **kwargs)
@classmethod
def get_filters(cls, key, **kwargs):
"""
Override filters method to ensure settings are filtered by plugin id
"""
filters = super().get_filters(key, **kwargs)
plugin = kwargs.get('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
return filters
plugin = models.ForeignKey(
PluginConfig,
related_name='settings',
null=False,
verbose_name=_('Plugin'),
on_delete=models.CASCADE,
)

View File

@ -1,5 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Base Class for InvenTree plugins""" """
Base Class for InvenTree plugins
"""
from django.db.utils import OperationalError, ProgrammingError
from django.utils.text import slugify
class InvenTreePlugin(): class InvenTreePlugin():
@ -7,12 +12,54 @@ class InvenTreePlugin():
Base class for a plugin Base class for a plugin
""" """
def __init__(self):
pass
# Override the plugin name for each concrete plugin instance # Override the plugin name for each concrete plugin instance
PLUGIN_NAME = '' PLUGIN_NAME = ''
PLUGIN_SLUG = None
PLUGIN_TITLE = None
def plugin_name(self): def plugin_name(self):
"""get plugin name""" """
Return the name of this plugin plugin
"""
return self.PLUGIN_NAME return self.PLUGIN_NAME
def __init__(self): def plugin_slug(self):
pass
slug = getattr(self, 'PLUGIN_SLUG', None)
if slug is None:
slug = self.plugin_name()
return slugify(slug.lower())
def plugin_title(self):
if self.PLUGIN_TITLE:
return self.PLUGIN_TITLE
else:
return self.plugin_name()
def plugin_config(self, raise_error=False):
"""
Return the PluginConfig object associated with this plugin
"""
try:
import plugin.models
cfg, _ = plugin.models.PluginConfig.objects.get_or_create(
key=self.plugin_slug(),
name=self.plugin_name(),
)
except (OperationalError, ProgrammingError) as error:
cfg = None
if raise_error:
raise error
return cfg

View File

@ -1,7 +1,10 @@
""" """
registry for plugins Registry for loading and managing multiple plugins at run-time
holds the class and the object that contains all code to maintain plugin states
- Holds the class and the object that contains all code to maintain plugin states
- Manages setup and teardown of plugin class instances
""" """
import importlib import importlib
import pathlib import pathlib
import logging import logging
@ -33,7 +36,11 @@ from .helpers import get_plugin_error, IntegrationPluginError
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
class Plugins: class PluginsRegistry:
"""
The PluginsRegistry class
"""
def __init__(self) -> None: def __init__(self) -> None:
# plugin registry # plugin registry
self.plugins = {} self.plugins = {}
@ -50,15 +57,19 @@ class Plugins:
# integration specific # integration specific
self.installed_apps = [] # Holds all added plugin_paths self.installed_apps = [] # Holds all added plugin_paths
# mixins # mixins
self.mixins_globalsettings = {} self.mixins_settings = {}
# region public plugin functions # region public plugin functions
def load_plugins(self): def load_plugins(self):
"""load and activate all IntegrationPlugins""" """
Load and activate all IntegrationPlugins
"""
from plugin.helpers import log_plugin_error from plugin.helpers import log_plugin_error
logger.info('Start loading plugins') logger.info('Start loading plugins')
# set maintanace mode
# Set maintanace mode
_maintenance = bool(get_maintenance_mode()) _maintenance = bool(get_maintenance_mode())
if not _maintenance: if not _maintenance:
set_maintenance_mode(True) set_maintenance_mode(True)
@ -68,7 +79,7 @@ class Plugins:
retry_counter = settings.PLUGIN_RETRY retry_counter = settings.PLUGIN_RETRY
while not registered_sucessfull: while not registered_sucessfull:
try: try:
# we are using the db so for migrations etc we need to try this block # We are using the db so for migrations etc we need to try this block
self._init_plugins(blocked_plugin) self._init_plugins(blocked_plugin)
self._activate_plugins() self._activate_plugins()
registered_sucessfull = True registered_sucessfull = True
@ -81,13 +92,14 @@ class Plugins:
log_plugin_error({error.path: error.message}, 'load') log_plugin_error({error.path: error.message}, 'load')
blocked_plugin = error.path # we will not try to load this app again blocked_plugin = error.path # we will not try to load this app again
# init apps without any integration plugins # Initialize apps without any integration plugins
self._clean_registry() self._clean_registry()
self._clean_installed_apps() self._clean_installed_apps()
self._activate_plugins(force_reload=True) self._activate_plugins(force_reload=True)
# we do not want to end in an endless loop # We do not want to end in an endless loop
retry_counter -= 1 retry_counter -= 1
if retry_counter <= 0: if retry_counter <= 0:
if settings.PLUGIN_TESTING: if settings.PLUGIN_TESTING:
print('[PLUGIN] Max retries, breaking loading') print('[PLUGIN] Max retries, breaking loading')
@ -98,15 +110,20 @@ class Plugins:
# now the loading will re-start up with init # now the loading will re-start up with init
# remove maintenance # Remove maintenance mode
if not _maintenance: if not _maintenance:
set_maintenance_mode(False) set_maintenance_mode(False)
logger.info('Finished loading plugins') logger.info('Finished loading plugins')
def unload_plugins(self): def unload_plugins(self):
"""unload and deactivate all IntegrationPlugins""" """
Unload and deactivate all IntegrationPlugins
"""
logger.info('Start unloading plugins') logger.info('Start unloading plugins')
# set maintanace mode
# Set maintanace mode
_maintenance = bool(get_maintenance_mode()) _maintenance = bool(get_maintenance_mode())
if not _maintenance: if not _maintenance:
set_maintenance_mode(True) set_maintenance_mode(True)
@ -123,21 +140,27 @@ class Plugins:
logger.info('Finished unloading plugins') logger.info('Finished unloading plugins')
def reload_plugins(self): def reload_plugins(self):
"""safely reload IntegrationPlugins""" """
# do not reload whe currently loading Safely reload IntegrationPlugins
"""
# Do not reload whe currently loading
if self.is_loading: if self.is_loading:
return return
logger.info('Start reloading plugins') logger.info('Start reloading plugins')
with maintenance_mode_on(): with maintenance_mode_on():
self.unload_plugins() self.unload_plugins()
self.load_plugins() self.load_plugins()
logger.info('Finished reloading plugins')
# endregion
# region general plugin managment mechanisms logger.info('Finished reloading plugins')
def collect_plugins(self): def collect_plugins(self):
"""collect integration plugins from all possible ways of loading""" """
Collect integration plugins from all possible ways of loading
"""
self.plugin_modules = [] # clear self.plugin_modules = [] # clear
# Collect plugins from paths # Collect plugins from paths
@ -146,7 +169,7 @@ class Plugins:
if modules: if modules:
[self.plugin_modules.append(item) for item in modules] [self.plugin_modules.append(item) for item in modules]
# check if not running in testing mode and apps should be loaded from hooks # Check if not running in testing mode and apps should be loaded from hooks
if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
# Collect plugins from setup entry points # Collect plugins from setup entry points
for entry in metadata.entry_points().get('inventree_plugins', []): for entry in metadata.entry_points().get('inventree_plugins', []):
@ -159,22 +182,25 @@ class Plugins:
logger.info(", ".join([a.__module__ for a in self.plugin_modules])) logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
def _init_plugins(self, disabled=None): def _init_plugins(self, disabled=None):
"""initialise all found plugins """
Initialise all found plugins
:param disabled: loading path of disabled app, defaults to None :param disabled: loading path of disabled app, defaults to None
:type disabled: str, optional :type disabled: str, optional
:raises error: IntegrationPluginError :raises error: IntegrationPluginError
""" """
from plugin.models import PluginConfig from plugin.models import PluginConfig
logger.info('Starting plugin initialisation') logger.info('Starting plugin initialisation')
# Initialize integration plugins # Initialize integration plugins
for plugin in self.plugin_modules: for plugin in self.plugin_modules:
# check if package # Check if package
was_packaged = getattr(plugin, 'is_package', False) was_packaged = getattr(plugin, 'is_package', False)
# check if activated # Check if activated
# these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
plug_name = plugin.PLUGIN_NAME plug_name = plugin.PLUGIN_NAME
plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
plug_key = slugify(plug_key) # keys are slugs! plug_key = slugify(plug_key) # keys are slugs!
@ -186,23 +212,23 @@ class Plugins:
raise error raise error
plugin_db_setting = None plugin_db_setting = None
# always activate if testing # Always activate if testing
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active): if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
# check if the plugin was blocked -> threw an error # Check if the plugin was blocked -> threw an error
if disabled: if disabled:
# option1: package, option2: file-based # option1: package, option2: file-based
if (plugin.__name__ == disabled) or (plugin.__module__ == disabled): if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
# errors are bad so disable the plugin in the database # Errors are bad so disable the plugin in the database
if not settings.PLUGIN_TESTING: if not settings.PLUGIN_TESTING:
plugin_db_setting.active = False plugin_db_setting.active = False
# TODO save the error to the plugin # TODO save the error to the plugin
plugin_db_setting.save(no_reload=True) plugin_db_setting.save(no_reload=True)
# add to inactive plugins so it shows up in the ui # Add to inactive plugins so it shows up in the ui
self.plugins_inactive[plug_key] = plugin_db_setting self.plugins_inactive[plug_key] = plugin_db_setting
continue # continue -> the plugin is not loaded continue # continue -> the plugin is not loaded
# init package # Initialize package
# now we can be sure that an admin has activated the plugin # now we can be sure that an admin has activated the plugin
# TODO check more stuff -> as of Nov 2021 there are not many checks in place # TODO check more stuff -> as of Nov 2021 there are not many checks in place
# but we could enhance those to check signatures, run the plugin against a whitelist etc. # but we could enhance those to check signatures, run the plugin against a whitelist etc.
@ -225,7 +251,8 @@ class Plugins:
self.plugins_inactive[plug_key] = plugin_db_setting self.plugins_inactive[plug_key] = plugin_db_setting
def _activate_plugins(self, force_reload=False): def _activate_plugins(self, force_reload=False):
"""run integration functions for all plugins """
Run integration functions for all plugins
:param force_reload: force reload base apps, defaults to False :param force_reload: force reload base apps, defaults to False
:type force_reload: bool, optional :type force_reload: bool, optional
@ -234,47 +261,37 @@ class Plugins:
plugins = self.plugins.items() plugins = self.plugins.items()
logger.info(f'Found {len(plugins)} active plugins') logger.info(f'Found {len(plugins)} active plugins')
self.activate_integration_globalsettings(plugins) self.activate_integration_settings(plugins)
self.activate_integration_app(plugins, force_reload=force_reload) self.activate_integration_app(plugins, force_reload=force_reload)
def _deactivate_plugins(self): def _deactivate_plugins(self):
"""run integration deactivation functions for all plugins""" """
Run integration deactivation functions for all plugins
"""
self.deactivate_integration_app() self.deactivate_integration_app()
self.deactivate_integration_globalsettings() self.deactivate_integration_settings()
# endregion
# region specific integrations def activate_integration_settings(self, plugins):
# region integration_globalsettings
def activate_integration_globalsettings(self, plugins):
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'): if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
logger.info('Registering IntegrationPlugin global settings') logger.info('Registering IntegrationPlugin global settings')
for slug, plugin in plugins: for slug, plugin in plugins:
if plugin.mixin_enabled('settings'): if plugin.mixin_enabled('settings'):
plugin_setting = plugin.globalsettingspatterns plugin_setting = plugin.settings
self.mixins_globalsettings[slug] = plugin_setting self.mixins_settings[slug] = plugin_setting
# Add to settings dir def deactivate_integration_settings(self):
InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
def deactivate_integration_globalsettings(self):
from common.models import InvenTreeSetting
# collect all settings # collect all settings
plugin_settings = {} plugin_settings = {}
for _, plugin_setting in self.mixins_globalsettings.items():
for _, plugin_setting in self.mixins_settings.items():
plugin_settings.update(plugin_setting) plugin_settings.update(plugin_setting)
# remove settings
for setting in plugin_settings:
InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
# clear cache # clear cache
self.mixins_globalsettings = {} self.mixins_Fsettings = {}
# endregion
# region integration_app
def activate_integration_app(self, plugins, force_reload=False): def activate_integration_app(self, plugins, force_reload=False):
"""activate AppMixin plugins - add custom apps and reload """activate AppMixin plugins - add custom apps and reload
@ -448,8 +465,6 @@ class Plugins:
return True, [] return True, []
except Exception as error: except Exception as error:
get_plugin_error(error, do_raise=True) get_plugin_error(error, do_raise=True)
# endregion
# endregion
plugins = Plugins() plugin_registry = PluginsRegistry()

View File

@ -44,6 +44,27 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
'default': True, 'default': True,
'validator': bool, 'validator': bool,
}, },
'API_KEY': {
'name': _('API Key'),
'description': _('Key required for accessing external API'),
},
'NUMERICAL_SETTING': {
'name': _('Numerical'),
'description': _('A numerical setting'),
'validator': int,
'default': 123,
},
'CHOICE_SETTING': {
'name': _("Choice Setting"),
'description': _('A setting with multiple choices'),
'choices': [
('A', 'Anaconda'),
('B', 'Bat'),
('C', 'Cat'),
('D', 'Dog'),
],
'default': 'A',
},
} }
NAVIGATION = [ NAVIGATION = [

View File

@ -1,5 +1,5 @@
""" """
JSON serializers for Stock app JSON serializers for plugin app
""" """
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
@ -14,11 +14,14 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from plugin.models import PluginConfig from common.serializers import SettingsSerializer
from plugin.models import PluginConfig, PluginSetting
class PluginConfigSerializer(serializers.ModelSerializer): class PluginConfigSerializer(serializers.ModelSerializer):
""" Serializer for a PluginConfig: """
Serializer for a PluginConfig:
""" """
meta = serializers.DictField(read_only=True) meta = serializers.DictField(read_only=True)
@ -71,7 +74,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
if not data.get('confirm'): if not data.get('confirm'):
raise ValidationError({'confirm': _('Installation not confirmed')}) raise ValidationError({'confirm': _('Installation not confirmed')})
if (not data.get('url')) and (not data.get('packagename')): if (not data.get('url')) and (not data.get('packagename')):
msg = _('Either packagenmae of url must be provided') msg = _('Either packagename of URL must be provided')
raise ValidationError({'url': msg, 'packagename': msg}) raise ValidationError({'url': msg, 'packagename': msg})
return data return data
@ -113,7 +116,28 @@ class PluginConfigInstallSerializer(serializers.Serializer):
ret['result'] = str(error.output, 'utf-8') ret['result'] = str(error.output, 'utf-8')
ret['error'] = True ret['error'] = True
# register plugins # Register plugins
# TODO # TODO
return ret return ret
class PluginSettingSerializer(SettingsSerializer):
"""
Serializer for the PluginSetting model
"""
plugin = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = PluginSetting
fields = [
'pk',
'key',
'value',
'name',
'description',
'type',
'choices',
'plugin',
]

View File

@ -7,7 +7,7 @@ from django import template
from django.urls import reverse from django.urls import reverse
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from plugin import plugin_reg from plugin import plugin_registry
register = template.Library() register = template.Library()
@ -16,19 +16,19 @@ register = template.Library()
@register.simple_tag() @register.simple_tag()
def plugin_list(*args, **kwargs): def plugin_list(*args, **kwargs):
""" Return a list of all installed integration plugins """ """ Return a list of all installed integration plugins """
return plugin_reg.plugins return plugin_registry.plugins
@register.simple_tag() @register.simple_tag()
def inactive_plugin_list(*args, **kwargs): def inactive_plugin_list(*args, **kwargs):
""" Return a list of all inactive integration plugins """ """ Return a list of all inactive integration plugins """
return plugin_reg.plugins_inactive return plugin_registry.plugins_inactive
@register.simple_tag() @register.simple_tag()
def plugin_globalsettings(plugin, *args, **kwargs): def plugin_settings(plugin, *args, **kwargs):
""" Return a list of all global settings for a plugin """ """ Return a list of all custom settings for a plugin """
return plugin_reg.mixins_globalsettings.get(plugin) return plugin_registry.mixins_settings.get(plugin)
@register.simple_tag() @register.simple_tag()
@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs):
@register.simple_tag() @register.simple_tag()
def plugin_errors(*args, **kwargs): def plugin_errors(*args, **kwargs):
"""Return all plugin errors""" """Return all plugin errors"""
return plugin_reg.errors return plugin_registry.errors

View File

@ -8,7 +8,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
class PluginDetailAPITest(InvenTreeAPITestCase): class PluginDetailAPITest(InvenTreeAPITestCase):
""" """
Tests the plugin AP I endpoints Tests the plugin API endpoints
""" """
roles = [ roles = [
@ -19,7 +19,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
] ]
def setUp(self): def setUp(self):
self.MSG_NO_PKG = 'Either packagenmae of url must be provided' self.MSG_NO_PKG = 'Either packagename of URL must be provided'
self.PKG_NAME = 'minimal' self.PKG_NAME = 'minimal'
super().setUp() super().setUp()
@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
Test the PluginConfig action commands Test the PluginConfig action commands
""" """
from plugin.models import PluginConfig from plugin.models import PluginConfig
from plugin import plugin_reg from plugin import plugin_registry
url = reverse('admin:plugin_pluginconfig_changelist') url = reverse('admin:plugin_pluginconfig_changelist')
fixtures = PluginConfig.objects.all() fixtures = PluginConfig.objects.all()
# check if plugins were registered -> in some test setups the startup has no db access # check if plugins were registered -> in some test setups the startup has no db access
if not fixtures: if not fixtures:
plugin_reg.reload_plugins() plugin_registry.reload_plugins()
fixtures = PluginConfig.objects.all() fixtures = PluginConfig.objects.all()
print([str(a) for a in fixtures]) print([str(a) for a in fixtures])

View File

@ -23,7 +23,7 @@ class BaseMixinDefinition:
class SettingsMixinTest(BaseMixinDefinition, TestCase): class SettingsMixinTest(BaseMixinDefinition, TestCase):
MIXIN_HUMAN_NAME = 'Settings' MIXIN_HUMAN_NAME = 'Settings'
MIXIN_NAME = 'settings' MIXIN_NAME = 'settings'
MIXIN_ENABLE_CHECK = 'has_globalsettings' MIXIN_ENABLE_CHECK = 'has_settings'
TEST_SETTINGS = {'SETTING1': {'default': '123', }} TEST_SETTINGS = {'SETTING1': {'default': '123', }}
@ -42,25 +42,19 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
def test_function(self): def test_function(self):
# settings variable # settings variable
self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS) self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
# settings pattern
target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.globalsettings.items()}
self.assertEqual(self.mixin.globalsettingspatterns, target_pattern)
# no settings
self.assertIsNone(self.mixin_nothing.globalsettings)
self.assertIsNone(self.mixin_nothing.globalsettingspatterns)
# calling settings # calling settings
# not existing # not existing
self.assertEqual(self.mixin.get_globalsetting('ABCD'), '') self.assertEqual(self.mixin.get_setting('ABCD'), '')
self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '') self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
# right setting # right setting
self.mixin.set_globalsetting('SETTING1', '12345', self.test_user) self.mixin.set_setting('SETTING1', '12345', self.test_user)
self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345') self.assertEqual(self.mixin.get_setting('SETTING1'), '12345')
# no setting # no setting
self.assertEqual(self.mixin_nothing.get_globalsetting(''), '') self.assertEqual(self.mixin_nothing.get_setting(''), '')
class UrlsMixinTest(BaseMixinDefinition, TestCase): class UrlsMixinTest(BaseMixinDefinition, TestCase):

View File

@ -1,4 +1,6 @@
""" Unit tests for plugins """ """
Unit tests for plugins
"""
from django.test import TestCase from django.test import TestCase
@ -6,9 +8,8 @@ import plugin.plugin
import plugin.integration import plugin.integration
from plugin.samples.integration.sample import SampleIntegrationPlugin from plugin.samples.integration.sample import SampleIntegrationPlugin
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
# from plugin.plugins import load_action_plugins, load_barcode_plugins
import plugin.templatetags.plugin_extras as plugin_tags import plugin.templatetags.plugin_extras as plugin_tags
from plugin import plugin_reg from plugin import plugin_registry
class InvenTreePluginTests(TestCase): class InvenTreePluginTests(TestCase):
@ -57,17 +58,17 @@ class PluginTagTests(TestCase):
def test_tag_plugin_list(self): def test_tag_plugin_list(self):
"""test that all plugins are listed""" """test that all plugins are listed"""
self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins) self.assertEqual(plugin_tags.plugin_list(), plugin_registry.plugins)
def test_tag_incative_plugin_list(self): def test_tag_incative_plugin_list(self):
"""test that all inactive plugins are listed""" """test that all inactive plugins are listed"""
self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive) self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_registry.plugins_inactive)
def test_tag_plugin_globalsettings(self): def test_tag_plugin_settings(self):
"""check all plugins are listed""" """check all plugins are listed"""
self.assertEqual( self.assertEqual(
plugin_tags.plugin_globalsettings(self.sample), plugin_tags.plugin_settings(self.sample),
plugin_reg.mixins_globalsettings.get(self.sample) plugin_registry.mixins_settings.get(self.sample)
) )
def test_tag_mixin_enabled(self): def test_tag_mixin_enabled(self):
@ -89,4 +90,4 @@ class PluginTagTests(TestCase):
def test_tag_plugin_errors(self): def test_tag_plugin_errors(self):
"""test that all errors are listed""" """test that all errors are listed"""
self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors) self.assertEqual(plugin_tags.plugin_errors(), plugin_registry.errors)

View File

@ -1,18 +1,24 @@
""" """
URL lookup for plugin app URL lookup for plugin app
""" """
from django.conf.urls import url, include from django.conf.urls import url, include
from plugin import plugin_reg from plugin import plugin_registry
PLUGIN_BASE = 'plugin' # Constant for links PLUGIN_BASE = 'plugin' # Constant for links
def get_plugin_urls(): def get_plugin_urls():
"""returns a urlpattern that can be integrated into the global urls""" """
Returns a urlpattern that can be integrated into the global urls
"""
urls = [] urls = []
for plugin in plugin_reg.plugins.values():
for plugin in plugin_registry.plugins.values():
if plugin.mixin_enabled('urls'): if plugin.mixin_enabled('urls'):
urls.append(plugin.urlpatterns) urls.append(plugin.urlpatterns)
return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin'))) return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))

View File

@ -13,19 +13,19 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-user-lock" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-at" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" icon='fa-key' %}
<tr> <tr>
<th><h5>{% trans 'Signup' %}</h5></th> <th><h5>{% trans 'Signup' %}</h5></th>
<td colspan='4'></td> <td colspan='4'></td>
</tr> </tr>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-user-plus" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-at" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-user-lock" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %}
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %} {% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" icon="fa-users" %}
</tbody> </tbody>
</table> </table>

View File

@ -5,12 +5,12 @@
<h4>{% trans "Settings" %}</h4> <h4>{% trans "Settings" %}</h4>
</div> </div>
{% plugin_globalsettings plugin_key as plugin_settings %} {% plugin_settings plugin_key as plugin_settings %}
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% for setting in plugin_settings %} {% for setting in plugin_settings %}
{% include "InvenTree/settings/setting.html" with key=setting%} {% include "InvenTree/settings/setting.html" with key=setting plugin=plugin %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -19,10 +19,9 @@
<div class='table-responsive'> <div class='table-responsive'>
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
{% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -70,7 +69,7 @@
{% if mixin_list %} {% if mixin_list %}
{% for mixin in mixin_list %} {% for mixin in mixin_list %}
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar"> <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
<span class='badge bg-dark badge-right'>{{ mixin.human_name }}</span> <span class='badge bg-dark badge-right rounded-pill'>{{ mixin.human_name }}</span>
</a> </a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@ -12,10 +12,10 @@
<table class='table table-striped table-condensed'> <table class='table table-striped table-condensed'>
<tbody> <tbody>
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %} {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %}
{% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %} {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
</tbody> </tbody>
</table> </table>

View File

@ -1,10 +1,12 @@
{% load inventree_extras %} {% load inventree_extras %}
{% load i18n %} {% load i18n %}
{% if user_setting %} {% if plugin %}
{% setting_object key user=request.user as setting %} {% setting_object key plugin=plugin as setting %}
{% elif user_setting %}
{% setting_object key user=request.user as setting %}
{% else %} {% else %}
{% setting_object key as setting %} {% setting_object key as setting %}
{% endif %} {% endif %}
<tr> <tr>
@ -13,7 +15,7 @@
<span class='fas {{ icon }}'></span> <span class='fas {{ icon }}'></span>
{% endif %} {% endif %}
</td> </td>
<td><strong>{% trans setting.name %}</strong></td> <td><strong>{{ setting.name }}</strong></td>
<td> <td>
{% if setting.is_bool %} {% if setting.is_bool %}
<div class='form-check form-switch'> <div class='form-check form-switch'>
@ -32,11 +34,11 @@
</div> </div>
{% endif %} {% endif %}
<td> <td>
{% trans setting.description %} {{ setting.description }}
</td> </td>
<td> <td>
<div class='btn-group float-right'> <div class='btn-group float-right'>
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if user_setting %}user='{{request.user.id}}'{% endif %}> <button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
<span class='fas fa-edit icon-green'></span> <span class='fas fa-edit icon-green'></span>
</button> </button>
</div> </div>

View File

@ -62,16 +62,27 @@
$('table').find('.btn-edit-setting').click(function() { $('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting'); var setting = $(this).attr('setting');
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
var plugin = $(this).attr('plugin');
var is_global = true; var is_global = true;
if ($(this).attr('user')){ if ($(this).attr('user')){
is_global = false; is_global = false;
} }
var title = '';
if (plugin != null) {
title = '{% trans "Edit Plugin Setting" %}';
} else if (is_global) {
title = '{% trans "Edit Global Setting" %}';
} else {
title = '{% trans "Edit User Setting" %}';
}
editSetting(pk, { editSetting(pk, {
plugin: plugin,
global: is_global, global: is_global,
title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}', title: title,
}); });
}); });

View File

@ -49,7 +49,7 @@
{% include "sidebar_header.html" with text="Plugin Settings" %} {% include "sidebar_header.html" with text="Plugin Settings" %}
{% include "sidebar_item.html" with label='plugin' text="Plugin" icon="fa-plug" %} {% include "sidebar_item.html" with label='plugin' text="Plugins" icon="fa-plug" %}
{% plugin_list as pl_list %} {% plugin_list as pl_list %}
{% for plugin_key, plugin in pl_list.items %} {% for plugin_key, plugin in pl_list.items %}

View File

@ -28,9 +28,13 @@ function editSetting(pk, options={}) {
// Is this a global setting or a user setting? // Is this a global setting or a user setting?
var global = options.global || false; var global = options.global || false;
var plugin = options.plugin;
var url = ''; var url = '';
if (global) { if (plugin) {
url = `/api/plugin/settings/${pk}/`;
} else if (global) {
url = `/api/settings/global/${pk}/`; url = `/api/settings/global/${pk}/`;
} else { } else {
url = `/api/settings/user/${pk}/`; url = `/api/settings/user/${pk}/`;

View File

@ -76,7 +76,8 @@ class RuleSet(models.Model):
'otp_totp_totpdevice', 'otp_totp_totpdevice',
'otp_static_statictoken', 'otp_static_statictoken',
'otp_static_staticdevice', 'otp_static_staticdevice',
'plugin_pluginconfig' 'plugin_pluginconfig',
'plugin_pluginsetting',
], ],
'part_category': [ 'part_category': [
'part_partcategory', 'part_partcategory',