diff --git a/InvenTree/InvenTree/management/commands/clean_settings.py b/InvenTree/InvenTree/management/commands/clean_settings.py index e0fd09e6c7..283416de29 100644 --- a/InvenTree/InvenTree/management/commands/clean_settings.py +++ b/InvenTree/InvenTree/management/commands/clean_settings.py @@ -2,9 +2,14 @@ Custom management command to cleanup old settings that are not defined anymore """ +import logging + from django.core.management.base import BaseCommand +logger = logging.getLogger('inventree') + + class Command(BaseCommand): """ Cleanup old (undefined) settings in the database @@ -12,27 +17,27 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): - print("Collecting settings") + logger.info("Collecting settings") from common.models import InvenTreeSetting, InvenTreeUserSetting # general settings db_settings = InvenTreeSetting.objects.all() - model_settings = InvenTreeSetting.GLOBAL_SETTINGS + model_settings = InvenTreeSetting.SETTINGS # check if key exist and delete if not for setting in db_settings: if setting.key not in model_settings: setting.delete() - print(f"deleted setting '{setting.key}'") + logger.info(f"deleted setting '{setting.key}'") # user settings db_settings = InvenTreeUserSetting.objects.all() - model_settings = InvenTreeUserSetting.GLOBAL_SETTINGS + model_settings = InvenTreeUserSetting.SETTINGS # check if key exist and delete if not for setting in db_settings: if setting.key not in model_settings: 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") diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 79bc44bc0e..1f8e372d39 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # 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 +v23 -> 2022-02-02 + - Adds API endpoints for managing plugin classes + - Adds API endpoints for managing plugin settings + v22 -> 2021-12-20 - Adds API endpoint to "merge" multiple stock items diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index c514d7c4a9..16d0be035a 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -53,7 +53,7 @@ class BaseInvenTreeSetting(models.Model): single values (e.g. one-off settings values). """ - GLOBAL_SETTINGS = {} + SETTINGS = {} class Meta: abstract = True @@ -65,7 +65,7 @@ class BaseInvenTreeSetting(models.Model): self.key = str(self.key).upper() - self.clean() + self.clean(**kwargs) self.validate_unique() super().save() @@ -82,6 +82,7 @@ class BaseInvenTreeSetting(models.Model): results = cls.objects.all() + # Optionally filter by user if user is not None: results = results.filter(user=user) @@ -93,13 +94,13 @@ class BaseInvenTreeSetting(models.Model): settings[setting.key.upper()] = setting.value # 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: settings[key.upper()] = cls.get_setting_default(key) if exclude_hidden: - hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False) + hidden = cls.SETTINGS[key].get('hidden', False) if hidden: # Remove hidden items @@ -123,98 +124,92 @@ class BaseInvenTreeSetting(models.Model): return settings @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. If it does not exist, return an empty string. """ - key = str(key).strip().upper() - - if key in cls.GLOBAL_SETTINGS: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('name', '') - else: - return '' + setting = cls.get_setting_definition(key, **kwargs) + return setting.get('name', '') @classmethod - def get_setting_description(cls, key): + def get_setting_description(cls, key, **kwargs): """ Return the description for a particular setting. 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: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('description', '') - else: - return '' + return setting.get('description', '') @classmethod - def get_setting_units(cls, key): + def get_setting_units(cls, key, **kwargs): """ Return the units for a particular setting. 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: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('units', '') - else: - return '' + return setting.get('units', '') @classmethod - def get_setting_validator(cls, key): + def get_setting_validator(cls, key, **kwargs): """ Return the validator for a particular setting. 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: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('validator', None) - else: - return None + return setting.get('validator', None) @classmethod - def get_setting_default(cls, key): + def get_setting_default(cls, key, **kwargs): """ Return the default value for a particular setting. 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: - setting = cls.GLOBAL_SETTINGS[key] - return setting.get('default', '') - else: - return '' + return setting.get('default', '') @classmethod - def get_setting_choices(cls, key): + def get_setting_choices(cls, key, **kwargs): """ 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: - setting = cls.GLOBAL_SETTINGS[key] - choices = setting.get('choices', None) - else: - choices = None + choices = setting.get('choices', None) if callable(choices): # 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() + settings = cls.objects.all() + + # Filter by user + user = kwargs.get('user', None) + + if user is not None: + settings = settings.filter(user=user) + try: - setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first() + setting = settings.filter(**cls.get_filters(key, **kwargs)).first() except (ValueError, cls.DoesNotExist): setting = None except (IntegrityError, OperationalError): 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) 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: # Wrap this statement in "atomic", so it can be rolled back if it fails @@ -259,21 +277,6 @@ class BaseInvenTreeSetting(models.Model): 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 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 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) if setting: value = setting.value - # If the particular setting is defined as a boolean, cast the value to a boolean - if setting.is_bool(): + # Cast to boolean if necessary + if setting.is_bool(**kwargs): value = InvenTree.helpers.str2bool(value) - if setting.is_int(): + # Cast to integer if necessary + if setting.is_int(**kwargs): try: value = int(value) except (ValueError, TypeError): @@ -357,7 +361,7 @@ class BaseInvenTreeSetting(models.Model): def units(self): 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, run them against the 'value' field. @@ -365,25 +369,16 @@ class BaseInvenTreeSetting(models.Model): super().clean() - validator = self.__class__.get_setting_validator(self.key) + validator = self.__class__.get_setting_validator(self.key, **kwargs) - if self.is_bool(): - self.value = InvenTree.helpers.str2bool(self.value) - - if self.is_int(): - try: - self.value = int(self.value) - except (ValueError): - raise ValidationError(_('Must be an integer value')) + if validator is not None: + self.run_validator(validator) options = self.valid_options() if options and self.value not in options: raise ValidationError(_("Chosen value is not a valid option")) - if validator is not None: - self.run_validator(validator) - def run_validator(self, validator): """ Run a validator against the 'value' field for this InvenTreeSetting object. @@ -395,7 +390,7 @@ class BaseInvenTreeSetting(models.Model): value = self.value # Boolean validator - if self.is_bool(): + if validator is bool: # Value must "look like" a boolean value if InvenTree.helpers.is_bool(value): # Coerce into either "True" or "False" @@ -406,7 +401,7 @@ class BaseInvenTreeSetting(models.Model): }) # Integer validator - if self.is_int(): + if validator is int: try: # Coerce into an integer value @@ -459,12 +454,12 @@ class BaseInvenTreeSetting(models.Model): 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 """ - validator = self.__class__.get_setting_validator(self.key) + validator = self.__class__.get_setting_validator(self.key, **kwargs) return self.__class__.validator_is_bool(validator) @@ -477,15 +472,15 @@ class BaseInvenTreeSetting(models.Model): return InvenTree.helpers.str2bool(self.value) - def setting_type(self): + def setting_type(self, **kwargs): """ Return the field type identifier for this setting object """ - if self.is_bool(): + if self.is_bool(**kwargs): return 'boolean' - elif self.is_int(): + elif self.is_int(**kwargs): return 'integer' else: @@ -504,12 +499,12 @@ class BaseInvenTreeSetting(models.Model): return False - def is_int(self): + def is_int(self, **kwargs): """ 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) @@ -541,21 +536,20 @@ class BaseInvenTreeSetting(models.Model): return value @classmethod - def is_protected(cls, key): + def is_protected(cls, key, **kwargs): """ 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 cls.GLOBAL_SETTINGS[key].get('protected', False) - else: - return False + return setting.get('protected', False) 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()]] @@ -595,7 +589,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): The keys must be upper-case """ - GLOBAL_SETTINGS = { + SETTINGS = { 'SERVER_RESTART_REQUIRED': { 'name': _('Restart required'), @@ -977,13 +971,6 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, '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': { 'name': _('Enable app integration'), 'description': _('Enable plugins to add apps'), @@ -1017,7 +1004,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 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: return options.get('requires_restart', False) @@ -1030,7 +1017,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): An InvenTreeSetting object with a usercontext """ - GLOBAL_SETTINGS = { + SETTINGS = { 'HOMEPAGE_PART_STARRED': { 'name': _('Show subscribed parts'), 'description': _('Show subscribed parts on the homepage'), diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index c20dc5d126..8bd5b6ffba 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -49,9 +49,9 @@ class SettingsTest(TestCase): - 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) @@ -64,14 +64,14 @@ class SettingsTest(TestCase): raise ValueError(f'Missing GLOBAL_SETTING description for {key}') 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): """ 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) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 9dc09535fa..9c0b0e691f 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -1,6 +1,7 @@ # -*- 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. """ @@ -22,6 +23,8 @@ import InvenTree.helpers from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting from common.settings import currency_code_default +from plugin.models import PluginSetting + register = template.Library() @@ -223,8 +226,16 @@ def setting_object(key, *args, **kwargs): 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: return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user']) + return InvenTreeSetting.get_setting_object(key) diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py index 973d341171..b3dc3a2fd0 100644 --- a/InvenTree/plugin/__init__.py +++ b/InvenTree/plugin/__init__.py @@ -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 .action import ActionPlugin __all__ = [ - 'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin', + 'ActionPlugin', + 'IntegrationPluginBase', + 'InvenTreePlugin', + 'plugin_registry', ] diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 3a96a4b9ea..b20aef8057 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -4,43 +4,70 @@ from __future__ import unicode_literals from django.contrib import admin import plugin.models as models -from plugin import plugin_reg +import plugin.registry as registry def plugin_update(queryset, new_status: bool): - """general function for bulk changing plugins""" + """ + General function for bulk changing plugins + """ + 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: if plugin.active is not new_status: plugin.active = new_status plugin.save(no_reload=True) apps_changed = True - # reload plugins if they changed + # Reload plugins if they changed if apps_changed: - plugin_reg.reload_plugins() + registry.plugin_registry.reload_plugins() @admin.action(description='Activate plugin(s)') def plugin_activate(modeladmin, request, queryset): - """activate a set of plugins""" + """ + Activate a set of plugins + """ plugin_update(queryset, True) @admin.action(description='Deactivate plugin(s)') def plugin_deactivate(modeladmin, request, queryset): - """deactivate a set of plugins""" + """ + Deactivate a set of plugins + """ + 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): - """Custom admin with restricted id fields""" + """ + Custom admin with restricted id fields + """ + readonly_fields = ["key", "name", ] - list_display = ['active', '__str__', 'key', 'name', ] + list_display = ['name', 'key', '__str__', 'active', ] list_filter = ['active'] actions = [plugin_activate, plugin_deactivate, ] + inlines = [PluginSettingInline, ] admin.site.register(models.PluginConfig, PluginConfigAdmin) diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 4aecd6bb24..9ab3b96724 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -11,7 +11,8 @@ from rest_framework import generics from rest_framework import status 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 @@ -76,7 +77,46 @@ class PluginInstall(generics.CreateAPIView): 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 settings URLs + url(r'^settings/', include([ + url(r'^(?P\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 url(r'^(?P\d+)/', include([ url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'), diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index 8a3cd97889..cca9dee91c 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -4,17 +4,17 @@ from __future__ import unicode_literals from django.apps import AppConfig from maintenance_mode.core import set_maintenance_mode -from plugin.registry import plugins +from plugin import plugin_registry class PluginAppConfig(AppConfig): name = 'plugin' def ready(self): - if not plugins.is_loading: + if not plugin_registry.is_loading: # this is the first startup - plugins.collect_plugins() - plugins.load_plugins() + plugin_registry.collect_plugins() + plugin_registry.load_plugins() # drop out of maintenance # makes sure we did not have an error in reloading and maintenance is still active diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index c7edc8ac36..c5d2411e4d 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -3,7 +3,9 @@ Plugin mixin classes """ 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 @@ -17,44 +19,38 @@ class SettingsMixin: def __init__(self): super().__init__() - self.add_mixin('settings', 'has_globalsettings', __class__) - self.globalsettings = getattr(self, 'SETTINGS', None) + self.add_mixin('settings', 'has_settings', __class__) + self.settings = getattr(self, 'SETTINGS', {}) @property - def has_globalsettings(self): + def has_settings(self): """ Does this plugin use custom global settings """ - return bool(self.globalsettings) + return bool(self.settings) - @property - def globalsettingspatterns(self): + def get_setting(self, key): """ - 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): - """ - Get global name of setting - """ - return f'PLUGIN_{self.slug.upper()}_{key}' + return PluginSetting.get_setting(key, plugin=self) - 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): - """ - set plugin global setting by key - """ - from common.models import InvenTreeSetting - return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user) + try: + plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name()) + except (OperationalError, ProgrammingError): + plugin = None + + if not plugin: + # Cannot find associated plugin model, return + return + + PluginSetting.set_setting(key, value, user, plugin=plugin) class UrlsMixin: diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index 003ca707b4..fb46df8927 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -10,14 +10,14 @@ from django.conf import settings # region logging / errors 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 - if reference not in plugin_reg.errors: - plugin_reg.errors[reference] = [] + if reference not in plugin_registry.errors: + plugin_registry.errors[reference] = [] # add error to stack - plugin_reg.errors[reference].append(error) + plugin_registry.errors[reference].append(error) class IntegrationPluginError(Exception): diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py index 3cd8ae86d2..73223593a5 100644 --- a/InvenTree/plugin/integration.py +++ b/InvenTree/plugin/integration.py @@ -9,7 +9,6 @@ import pathlib from django.urls.base import reverse from django.conf import settings -from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ import plugin.plugin as plugin @@ -20,19 +19,27 @@ logger = logging.getLogger("inventree") class MixinBase: - """general base for mixins""" + """ + General base for mixins + """ def __init__(self) -> None: self._mixinreg = {} self._mixins = {} 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.setup_mixin(key, cls=cls) 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 human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key @@ -44,7 +51,10 @@ class MixinBase: @property 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) if mixins: # filter out base @@ -59,8 +69,6 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): """ The IntegrationPluginBase class is used to integrate with 3rd party software """ - PLUGIN_SLUG = None - PLUGIN_TITLE = None AUTHOR = None DESCRIPTION = None @@ -84,11 +92,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): # region properties @property def slug(self): - """slug for the plugin""" - slug = getattr(self, 'PLUGIN_SLUG', None) - if not slug: - slug = self.plugin_name() - return slugify(slug) + return self.plugin_slug() @property def human_name(self): diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py index 2491336a51..2d17f8c36f 100644 --- a/InvenTree/plugin/loader.py +++ b/InvenTree/plugin/loader.py @@ -4,7 +4,7 @@ load templates for loaded plugins from django.template.loaders.filesystem import Loader as FilesystemLoader from pathlib import Path -from plugin import plugin_reg +from plugin import plugin_registry class PluginTemplateLoader(FilesystemLoader): @@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader): def get_dirs(self): dirname = 'templates' template_dirs = [] - for plugin in plugin_reg.plugins.values(): + for plugin in plugin_registry.plugins.values(): new_path = Path(plugin.path) / dirname if Path(new_path).is_dir(): template_dirs.append(new_path) diff --git a/InvenTree/plugin/migrations/0003_pluginsetting.py b/InvenTree/plugin/migrations/0003_pluginsetting.py new file mode 100644 index 0000000000..83e744fa6b --- /dev/null +++ b/InvenTree/plugin/migrations/0003_pluginsetting.py @@ -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')}, + }, + ), + ] diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 93c6335497..8b81eb2062 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -8,16 +8,17 @@ from __future__ import unicode_literals from django.utils.translation import gettext_lazy as _ from django.db import models -from plugin import plugin_reg +import common.models + +from plugin import InvenTreePlugin, plugin_registry class PluginConfig(models.Model): - """ A PluginConfig object holds settings for plugins. - - It is used to designate a Part as 'subscribed' for a given User. + """ + A PluginConfig object holds settings for plugins. 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 active: Should the plugin be loaded? """ @@ -63,12 +64,15 @@ class PluginConfig(models.Model): # functions 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) self.__org_active = self.active # 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): if self.plugin: @@ -82,16 +86,112 @@ class PluginConfig(models.Model): } 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 ret = super().save(force_insert, force_update, *args, **kwargs) if not reload: 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: - plugin_reg.reload_plugins() + plugin_registry.reload_plugins() 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, + ) diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index 93199df7b7..35643b36c3 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -1,5 +1,10 @@ # -*- 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(): @@ -7,12 +12,54 @@ class InvenTreePlugin(): Base class for a plugin """ + def __init__(self): + pass + # Override the plugin name for each concrete plugin instance PLUGIN_NAME = '' + PLUGIN_SLUG = None + + PLUGIN_TITLE = None + def plugin_name(self): - """get plugin name""" + """ + Return the name of this plugin plugin + """ return self.PLUGIN_NAME - def __init__(self): - pass + def plugin_slug(self): + + 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 diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index d858d6c7f0..fe28acfadb 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -1,7 +1,10 @@ """ -registry for plugins -holds the class and the object that contains all code to maintain plugin states +Registry for loading and managing multiple plugins at run-time + +- 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 pathlib import logging @@ -33,7 +36,11 @@ from .helpers import get_plugin_error, IntegrationPluginError logger = logging.getLogger('inventree') -class Plugins: +class PluginsRegistry: + """ + The PluginsRegistry class + """ + def __init__(self) -> None: # plugin registry self.plugins = {} @@ -50,15 +57,19 @@ class Plugins: # integration specific self.installed_apps = [] # Holds all added plugin_paths # mixins - self.mixins_globalsettings = {} + self.mixins_settings = {} # region public plugin functions def load_plugins(self): - """load and activate all IntegrationPlugins""" + """ + Load and activate all IntegrationPlugins + """ + from plugin.helpers import log_plugin_error logger.info('Start loading plugins') - # set maintanace mode + + # Set maintanace mode _maintenance = bool(get_maintenance_mode()) if not _maintenance: set_maintenance_mode(True) @@ -68,7 +79,7 @@ class Plugins: retry_counter = settings.PLUGIN_RETRY while not registered_sucessfull: 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._activate_plugins() registered_sucessfull = True @@ -81,13 +92,14 @@ class Plugins: log_plugin_error({error.path: error.message}, 'load') 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_installed_apps() 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 + if retry_counter <= 0: if settings.PLUGIN_TESTING: print('[PLUGIN] Max retries, breaking loading') @@ -98,15 +110,20 @@ class Plugins: # now the loading will re-start up with init - # remove maintenance + # Remove maintenance mode if not _maintenance: set_maintenance_mode(False) + logger.info('Finished loading plugins') def unload_plugins(self): - """unload and deactivate all IntegrationPlugins""" + """ + Unload and deactivate all IntegrationPlugins + """ + logger.info('Start unloading plugins') - # set maintanace mode + + # Set maintanace mode _maintenance = bool(get_maintenance_mode()) if not _maintenance: set_maintenance_mode(True) @@ -123,21 +140,27 @@ class Plugins: logger.info('Finished unloading plugins') 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: return logger.info('Start reloading plugins') + with maintenance_mode_on(): self.unload_plugins() self.load_plugins() - logger.info('Finished reloading plugins') - # endregion - # region general plugin managment mechanisms + logger.info('Finished reloading plugins') + 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 # Collect plugins from paths @@ -146,7 +169,7 @@ class Plugins: if 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): # Collect plugins from setup entry points 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])) def _init_plugins(self, disabled=None): - """initialise all found plugins + """ + Initialise all found plugins :param disabled: loading path of disabled app, defaults to None :type disabled: str, optional :raises error: IntegrationPluginError """ + from plugin.models import PluginConfig logger.info('Starting plugin initialisation') + # Initialize integration plugins for plugin in self.plugin_modules: - # check if package + # Check if package was_packaged = getattr(plugin, 'is_package', False) - # check if activated - # these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! + # Check if activated + # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!! plug_name = plugin.PLUGIN_NAME plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name plug_key = slugify(plug_key) # keys are slugs! @@ -186,23 +212,23 @@ class Plugins: raise error 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): - # check if the plugin was blocked -> threw an error + # Check if the plugin was blocked -> threw an error if disabled: # option1: package, option2: file-based 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: plugin_db_setting.active = False # TODO save the error to the plugin 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 continue # continue -> the plugin is not loaded - # init package + # Initialize package # 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 # 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 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 :type force_reload: bool, optional @@ -234,47 +261,37 @@ class Plugins: plugins = self.plugins.items() 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) 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_globalsettings() - # endregion + self.deactivate_integration_settings() - # region specific integrations - # region integration_globalsettings - def activate_integration_globalsettings(self, plugins): + def activate_integration_settings(self, plugins): from common.models import InvenTreeSetting if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'): logger.info('Registering IntegrationPlugin global settings') for slug, plugin in plugins: if plugin.mixin_enabled('settings'): - plugin_setting = plugin.globalsettingspatterns - self.mixins_globalsettings[slug] = plugin_setting + plugin_setting = plugin.settings + self.mixins_settings[slug] = plugin_setting - # Add to settings dir - InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting) - - def deactivate_integration_globalsettings(self): - from common.models import InvenTreeSetting + def deactivate_integration_settings(self): # collect all settings plugin_settings = {} - for _, plugin_setting in self.mixins_globalsettings.items(): + + for _, plugin_setting in self.mixins_settings.items(): plugin_settings.update(plugin_setting) - # remove settings - for setting in plugin_settings: - InvenTreeSetting.GLOBAL_SETTINGS.pop(setting) - # clear cache - self.mixins_globalsettings = {} - # endregion + self.mixins_Fsettings = {} - # region integration_app def activate_integration_app(self, plugins, force_reload=False): """activate AppMixin plugins - add custom apps and reload @@ -448,8 +465,6 @@ class Plugins: return True, [] except Exception as error: get_plugin_error(error, do_raise=True) - # endregion - # endregion -plugins = Plugins() +plugin_registry = PluginsRegistry() diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py index afc4a8fe8a..a05e804def 100644 --- a/InvenTree/plugin/samples/integration/sample.py +++ b/InvenTree/plugin/samples/integration/sample.py @@ -44,6 +44,27 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi 'default': True, '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 = [ diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index e25e253498..cc999a5a4f 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -1,5 +1,5 @@ """ -JSON serializers for Stock app +JSON serializers for plugin app """ # -*- coding: utf-8 -*- @@ -14,11 +14,14 @@ from django.utils.translation import ugettext_lazy as _ 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): - """ Serializer for a PluginConfig: + """ + Serializer for a PluginConfig: """ meta = serializers.DictField(read_only=True) @@ -71,7 +74,7 @@ class PluginConfigInstallSerializer(serializers.Serializer): if not data.get('confirm'): raise ValidationError({'confirm': _('Installation not confirmed')}) 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}) return data @@ -113,7 +116,28 @@ class PluginConfigInstallSerializer(serializers.Serializer): ret['result'] = str(error.output, 'utf-8') ret['error'] = True - # register plugins + # Register plugins # TODO 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', + ] diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index 1b4b269844..7852133ebb 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -7,7 +7,7 @@ from django import template from django.urls import reverse from common.models import InvenTreeSetting -from plugin import plugin_reg +from plugin import plugin_registry register = template.Library() @@ -16,19 +16,19 @@ register = template.Library() @register.simple_tag() def plugin_list(*args, **kwargs): """ Return a list of all installed integration plugins """ - return plugin_reg.plugins + return plugin_registry.plugins @register.simple_tag() def inactive_plugin_list(*args, **kwargs): """ Return a list of all inactive integration plugins """ - return plugin_reg.plugins_inactive + return plugin_registry.plugins_inactive @register.simple_tag() -def plugin_globalsettings(plugin, *args, **kwargs): - """ Return a list of all global settings for a plugin """ - return plugin_reg.mixins_globalsettings.get(plugin) +def plugin_settings(plugin, *args, **kwargs): + """ Return a list of all custom settings for a plugin """ + return plugin_registry.mixins_settings.get(plugin) @register.simple_tag() @@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs): @register.simple_tag() def plugin_errors(*args, **kwargs): """Return all plugin errors""" - return plugin_reg.errors + return plugin_registry.errors diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py index 0bb5f7789b..fdc97e407b 100644 --- a/InvenTree/plugin/test_api.py +++ b/InvenTree/plugin/test_api.py @@ -8,7 +8,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase class PluginDetailAPITest(InvenTreeAPITestCase): """ - Tests the plugin AP I endpoints + Tests the plugin API endpoints """ roles = [ @@ -19,7 +19,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase): ] 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' super().setUp() @@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase): Test the PluginConfig action commands """ from plugin.models import PluginConfig - from plugin import plugin_reg + from plugin import plugin_registry url = reverse('admin:plugin_pluginconfig_changelist') fixtures = PluginConfig.objects.all() # check if plugins were registered -> in some test setups the startup has no db access if not fixtures: - plugin_reg.reload_plugins() + plugin_registry.reload_plugins() fixtures = PluginConfig.objects.all() print([str(a) for a in fixtures]) diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py index 1371535cfa..3d88fed4dd 100644 --- a/InvenTree/plugin/test_integration.py +++ b/InvenTree/plugin/test_integration.py @@ -23,7 +23,7 @@ class BaseMixinDefinition: class SettingsMixinTest(BaseMixinDefinition, TestCase): MIXIN_HUMAN_NAME = 'Settings' MIXIN_NAME = 'settings' - MIXIN_ENABLE_CHECK = 'has_globalsettings' + MIXIN_ENABLE_CHECK = 'has_settings' TEST_SETTINGS = {'SETTING1': {'default': '123', }} @@ -42,25 +42,19 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase): def test_function(self): # settings variable - self.assertEqual(self.mixin.globalsettings, 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) + self.assertEqual(self.mixin.settings, self.TEST_SETTINGS) # calling settings # not existing - self.assertEqual(self.mixin.get_globalsetting('ABCD'), '') - self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '') + self.assertEqual(self.mixin.get_setting('ABCD'), '') + self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '') + # right setting - self.mixin.set_globalsetting('SETTING1', '12345', self.test_user) - self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345') + self.mixin.set_setting('SETTING1', '12345', self.test_user) + self.assertEqual(self.mixin.get_setting('SETTING1'), '12345') + # no setting - self.assertEqual(self.mixin_nothing.get_globalsetting(''), '') + self.assertEqual(self.mixin_nothing.get_setting(''), '') class UrlsMixinTest(BaseMixinDefinition, TestCase): diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index 3e0a1967db..2013ad43c8 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -1,4 +1,6 @@ -""" Unit tests for plugins """ +""" +Unit tests for plugins +""" from django.test import TestCase @@ -6,9 +8,8 @@ import plugin.plugin import plugin.integration from plugin.samples.integration.sample import SampleIntegrationPlugin 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 -from plugin import plugin_reg +from plugin import plugin_registry class InvenTreePluginTests(TestCase): @@ -57,17 +58,17 @@ class PluginTagTests(TestCase): def test_tag_plugin_list(self): """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): """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""" self.assertEqual( - plugin_tags.plugin_globalsettings(self.sample), - plugin_reg.mixins_globalsettings.get(self.sample) + plugin_tags.plugin_settings(self.sample), + plugin_registry.mixins_settings.get(self.sample) ) def test_tag_mixin_enabled(self): @@ -89,4 +90,4 @@ class PluginTagTests(TestCase): def test_tag_plugin_errors(self): """test that all errors are listed""" - self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors) + self.assertEqual(plugin_tags.plugin_errors(), plugin_registry.errors) diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py index 419dce5a88..1457aaf6f1 100644 --- a/InvenTree/plugin/urls.py +++ b/InvenTree/plugin/urls.py @@ -1,18 +1,24 @@ """ URL lookup for plugin app """ + from django.conf.urls import url, include -from plugin import plugin_reg +from plugin import plugin_registry PLUGIN_BASE = 'plugin' # Constant for links 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 = [] - for plugin in plugin_reg.plugins.values(): + + for plugin in plugin_registry.plugins.values(): if plugin.mixin_enabled('urls'): urls.append(plugin.urlpatterns) + return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin'))) diff --git a/InvenTree/templates/InvenTree/settings/login.html b/InvenTree/templates/InvenTree/settings/login.html index b7f32465c6..a52e35fb12 100644 --- a/InvenTree/templates/InvenTree/settings/login.html +++ b/InvenTree/templates/InvenTree/settings/login.html @@ -13,19 +13,19 @@ - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %} + {% 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-user-lock" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-at" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" icon='fa-key' %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %} - {% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %} + {% 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-at" %} + {% 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-key" %} + {% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" icon="fa-users" %}
{% trans 'Signup' %}
diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html index 910d4bbfbb..57218b3699 100644 --- a/InvenTree/templates/InvenTree/settings/mixins/settings.html +++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html @@ -5,12 +5,12 @@

{% trans "Settings" %}

-{% plugin_globalsettings plugin_key as plugin_settings %} +{% plugin_settings plugin_key as 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 %}
\ No newline at end of file diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html index ac3129eddc..960ec852b8 100644 --- a/InvenTree/templates/InvenTree/settings/plugin.html +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -19,10 +19,9 @@
- {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %} - {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %} - {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%} - {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} + {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
@@ -70,7 +69,7 @@ {% if mixin_list %} {% for mixin in mixin_list %} - {{ mixin.human_name }} + {{ mixin.human_name }} {% endfor %} {% endif %} diff --git a/InvenTree/templates/InvenTree/settings/report.html b/InvenTree/templates/InvenTree/settings/report.html index 89d26feba6..54c7175508 100644 --- a/InvenTree/templates/InvenTree/settings/report.html +++ b/InvenTree/templates/InvenTree/settings/report.html @@ -12,10 +12,10 @@ - {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %} - {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %} - {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %} - {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %} + {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %} + {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %} + {% 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" icon="fa-vial" %}
diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 7419b7ff34..16fc67ef86 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -1,10 +1,12 @@ {% load inventree_extras %} {% load i18n %} -{% if user_setting %} - {% setting_object key user=request.user as setting %} +{% if plugin %} +{% setting_object key plugin=plugin as setting %} +{% elif user_setting %} +{% setting_object key user=request.user as setting %} {% else %} - {% setting_object key as setting %} +{% setting_object key as setting %} {% endif %} @@ -13,7 +15,7 @@ {% endif %} - {% trans setting.name %} + {{ setting.name }} {% if setting.is_bool %}
@@ -32,11 +34,11 @@
{% endif %} - {% trans setting.description %} + {{ setting.description }}
-
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index cb87c6765b..9b3d2d21de 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -62,16 +62,27 @@ $('table').find('.btn-edit-setting').click(function() { var setting = $(this).attr('setting'); var pk = $(this).attr('pk'); - + var plugin = $(this).attr('plugin'); var is_global = true; if ($(this).attr('user')){ 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, { + plugin: plugin, global: is_global, - title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}', + title: title, }); }); diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 13c370ac16..24f62f1e1c 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -49,7 +49,7 @@ {% 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 %} {% for plugin_key, plugin in pl_list.items %} diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index e19bba6501..133edfba20 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -28,9 +28,13 @@ function editSetting(pk, options={}) { // Is this a global setting or a user setting? var global = options.global || false; + var plugin = options.plugin; + var url = ''; - if (global) { + if (plugin) { + url = `/api/plugin/settings/${pk}/`; + } else if (global) { url = `/api/settings/global/${pk}/`; } else { url = `/api/settings/user/${pk}/`; diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 5f2109da82..490f87f75a 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -76,7 +76,8 @@ class RuleSet(models.Model): 'otp_totp_totpdevice', 'otp_static_statictoken', 'otp_static_staticdevice', - 'plugin_pluginconfig' + 'plugin_pluginconfig', + 'plugin_pluginsetting', ], 'part_category': [ 'part_partcategory',