Adds API endpoints for viewing and updating plugin settings

A lot of code updates / refactoring here to get this to work as expected
This commit is contained in:
Oliver 2022-01-02 14:12:34 +11:00
parent f3bfe6e7ca
commit dc9e25ebad
23 changed files with 250 additions and 80 deletions

View File

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

View File

@ -71,7 +71,7 @@ class BaseInvenTreeSetting(models.Model):
super().save()
@classmethod
def allValues(cls, user=None, plugin=None, exclude_hidden=False):
def allValues(cls, user=None, exclude_hidden=False):
"""
Return a dict of "all" defined global settings.
@ -86,10 +86,6 @@ class BaseInvenTreeSetting(models.Model):
if user is not None:
results = results.filter(user=user)
# Optionally filter by plugin
if plugin is not None:
results = results.filter(plugin=plugin)
# Query the database
settings = {}
@ -238,16 +234,12 @@ class BaseInvenTreeSetting(models.Model):
settings = cls.objects.all()
# Filter by user
user = kwargs.get('user', None)
if user is not None:
settings = settings.filter(user=user)
plugin = kwargs.get('plugin', None)
if plugin is not None:
settings = settings.filter(plugin=plugin)
try:
setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
except (ValueError, cls.DoesNotExist):
@ -255,6 +247,16 @@ class BaseInvenTreeSetting(models.Model):
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:
@ -554,7 +556,9 @@ class BaseInvenTreeSetting(models.Model):
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()]]

View File

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

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 .action import ActionPlugin
__all__ = [
'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin',
'ActionPlugin',
'IntegrationPluginBase',
'InvenTreePlugin',
'plugin_registry',
]

View File

@ -6,6 +6,7 @@ from django.contrib import admin
import plugin.models as models
import plugin.registry as registry
def plugin_update(queryset, new_status: bool):
"""general function for bulk changing plugins"""
apps_changed = False

View File

@ -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<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
url(r'^(?P<pk>\d+)/', include([
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 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

View File

@ -36,17 +36,14 @@ class SettingsMixin:
# Find the plugin configuration associated with this plugin
try:
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
except (OperationalError, ProgrammingError) as error:
plugin = None
plugin = self.plugin_config()
if not plugin:
if plugin:
return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings)
else:
# Plugin cannot be found, return default value
return PluginSetting.get_setting_default(key, settings=self.settings)
return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings)
def set_setting(self, key, value, user):
"""
Set plugin setting value by key
@ -54,7 +51,7 @@ class SettingsMixin:
try:
plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
except (OperationalError, ProgrammingError) as error:
except (OperationalError, ProgrammingError):
plugin = None
if not plugin:

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ from django.db import models
import common.models
from plugin import plugin_reg
from plugin import InvenTreePlugin, plugin_registry
class PluginConfig(models.Model):
@ -72,7 +72,7 @@ class PluginConfig(models.Model):
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:
@ -95,10 +95,10 @@ class PluginConfig(models.Model):
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
@ -113,6 +113,58 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
('plugin', 'key'),
]
"""
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):
"""
@ -124,6 +176,8 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
plugin = kwargs.get('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePlugin):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
return filters

View File

@ -1,7 +1,9 @@
# -*- 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
@ -10,6 +12,9 @@ class InvenTreePlugin():
Base class for a plugin
"""
def __init__(self):
pass
# Override the plugin name for each concrete plugin instance
PLUGIN_NAME = ''
@ -36,5 +41,22 @@ class InvenTreePlugin():
return self.PLUGIN_TITLE
def __init__(self):
pass
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

@ -33,7 +33,7 @@ from .helpers import get_plugin_error, IntegrationPluginError
logger = logging.getLogger('inventree')
class Plugins:
class PluginsRegistry:
def __init__(self) -> None:
# plugin registry
self.plugins = {}
@ -225,7 +225,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
@ -238,13 +239,13 @@ class 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
# region specific integrations
# region integration_globalsettings
def activate_integration_globalsettings(self, plugins):
from common.models import InvenTreeSetting
@ -255,24 +256,16 @@ class Plugins:
plugin_setting = plugin.settings
self.mixins_settings[slug] = plugin_setting
# Add to settings dir
InvenTreeSetting.SETTINGS.update(plugin_setting)
def deactivate_integration_globalsettings(self):
from common.models import InvenTreeSetting
# collect all settings
plugin_settings = {}
for _, plugin_setting in self.mixins_settings.items():
plugin_settings.update(plugin_setting)
# remove settings
for setting in plugin_settings:
InvenTreeSetting.SETTINGS.pop(setting)
# clear cache
self.mixins_Fsettings = {}
# endregion
# region integration_app
def activate_integration_app(self, plugins, force_reload=False):
@ -452,4 +445,4 @@ class Plugins:
# endregion
plugins = Plugins()
plugin_registry = PluginsRegistry()

View File

@ -52,6 +52,18 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
'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',
},
}

View File

@ -14,7 +14,9 @@ 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):
@ -117,3 +119,24 @@ class PluginConfigInstallSerializer(serializers.Serializer):
# 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',
]

View File

@ -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_settings(plugin, *args, **kwargs):
""" Return a list of all custom settings for a plugin """
return plugin_reg.mixins_settings.get(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

View File

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

View File

@ -8,7 +8,7 @@ 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 +57,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_settings(self):
"""check all plugins are listed"""
self.assertEqual(
plugin_tags.plugin_settings(self.sample),
plugin_reg.mixins_settings.get(self.sample)
plugin_registry.mixins_settings.get(self.sample)
)
def test_tag_mixin_enabled(self):
@ -89,4 +89,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)

View File

@ -3,7 +3,7 @@ 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
@ -12,7 +12,7 @@ PLUGIN_BASE = 'plugin' # Constant for links
def get_plugin_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')))

View File

@ -10,7 +10,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% for setting in plugin_settings %}
{% include "InvenTree/settings/setting.html" with key=setting%}
{% include "InvenTree/settings/setting.html" with key=setting plugin=plugin %}
{% endfor %}
</tbody>
</table>

View File

@ -1,7 +1,9 @@
{% load inventree_extras %}
{% load i18n %}
{% if user_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 %}

View File

@ -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}/`;