2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

UI plugins custom features (#8137)

* initial implementation to let plugins provide custom ui features

* provide exportable types

* refactor ref into renderContext to make it more generic and support template preview area ui plugins

* rename 'renderContext' -> 'featureContext' as not all features may render something

* allow to specify the function name via the source file string divided by a colon

* Bump api version

* add tests

* add docs

* add docs

* debug: workflow

* debug: workflow

* fix tests

* fix tests hopefully

* apply suggestions from codereview

* trigger: ci

* Prove that coverage does not work

* Revert "Prove that coverage does not work"

This reverts commit 920c58ea6f.

* potentially fix test???
This commit is contained in:
Lukas
2024-09-26 11:59:37 +02:00
committed by GitHub
parent 4d48a10bdd
commit 35362347a7
24 changed files with 974 additions and 265 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 258
INVENTREE_API_VERSION = 259
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v259 - 2024-09-20 : https://github.com/inventree/InvenTree/pull/8137
- Implements new API endpoint for enabling custom UI features via plugins
v258 - 2024-09-24 : https://github.com/inventree/InvenTree/pull/8163
- Enhances the existing PartScheduling API endpoint
- Adds a formal DRF serializer to the endpoint

View File

@ -11,15 +11,12 @@ from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
import plugin.serializers as PluginSerializers
from common.api import GlobalSettingsPermissions
from common.settings import get_global_setting
from InvenTree.api import MetadataView
from InvenTree.exceptions import log_error
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (
CreateAPI,
@ -33,6 +30,7 @@ from plugin import registry
from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView
from plugin.base.ui.api import ui_plugins_api_urls
from plugin.models import PluginConfig, PluginSetting
from plugin.plugin import InvenTreePlugin
@ -417,43 +415,6 @@ class RegistryStatusView(APIView):
return Response(result)
class PluginPanelList(APIView):
"""API endpoint for listing all available plugin panels."""
permission_classes = [IsAuthenticated]
serializer_class = PluginSerializers.PluginPanelSerializer
@extend_schema(responses={200: PluginSerializers.PluginPanelSerializer(many=True)})
def get(self, request):
"""Show available plugin panels."""
target_model = request.query_params.get('target_model', None)
target_id = request.query_params.get('target_id', None)
panels = []
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# Extract all plugins from the registry which provide custom panels
for _plugin in registry.with_mixin('ui', active=True):
try:
# Allow plugins to fill this data out
plugin_panels = _plugin.get_ui_panels(
target_model, target_id, request
)
if plugin_panels and type(plugin_panels) is list:
for panel in plugin_panels:
panel['plugin'] = _plugin.slug
# TODO: Validate each panel before inserting
panels.append(panel)
except Exception:
# Custom panels could not load
# Log the error and continue
log_error(f'{_plugin.slug}.get_ui_panels')
return Response(PluginSerializers.PluginPanelSerializer(panels, many=True).data)
class PluginMetadataView(MetadataView):
"""Metadata API endpoint for the PluginConfig model."""
@ -468,21 +429,8 @@ plugin_api_urls = [
path(
'plugins/',
include([
path(
'ui/',
include([
path(
'panels/',
include([
path(
'',
PluginPanelList.as_view(),
name='api-plugin-panel-list',
)
]),
)
]),
),
# UI plugins
path('ui/', include(ui_plugins_api_urls)),
# Plugin management
path('reload/', PluginReload.as_view(), name='api-plugin-reload'),
path('install/', PluginInstall.as_view(), name='api-plugin-install'),

View File

@ -8,8 +8,7 @@ from django.urls import include, path, re_path, reverse
from error_report.models import Error
from common.models import InvenTreeSetting
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
from InvenTree.unit_test import InvenTreeTestCase
from plugin import InvenTreePlugin
from plugin.base.integration.PanelMixin import PanelMixin
from plugin.helpers import MixinNotImplementedError
@ -479,100 +478,3 @@ class PanelMixinTests(InvenTreeTestCase):
plugin = Wrong()
plugin.get_custom_panels('abc', 'abc')
class UserInterfaceMixinTests(InvenTreeAPITestCase):
"""Test the UserInterfaceMixin plugin mixin class."""
roles = 'all'
fixtures = ['part', 'category', 'location', 'stock']
@classmethod
def setUpTestData(cls):
"""Set up the test case."""
super().setUpTestData()
# Ensure that the 'sampleui' plugin is installed and active
registry.set_plugin_state('sampleui', True)
# Ensure that UI plugins are enabled
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
def test_installed(self):
"""Test that the sample UI plugin is installed and active."""
plugin = registry.get_plugin('sampleui')
self.assertTrue(plugin.is_active())
plugins = registry.with_mixin('ui')
self.assertGreater(len(plugins), 0)
def test_panels(self):
"""Test that the sample UI plugin provides custom panels."""
from part.models import Part
plugin = registry.get_plugin('sampleui')
_part = Part.objects.first()
# Ensure that the part is active
_part.active = True
_part.save()
url = reverse('api-plugin-panel-list')
query_data = {'target_model': 'part', 'target_id': _part.pk}
# Enable *all* plugin settings
plugin.set_setting('ENABLE_PART_PANELS', True)
plugin.set_setting('ENABLE_PURCHASE_ORDER_PANELS', True)
plugin.set_setting('ENABLE_BROKEN_PANELS', True)
plugin.set_setting('ENABLE_DYNAMIC_PANEL', True)
# Request custom panel information for a part instance
response = self.get(url, data=query_data)
# There should be 4 active panels for the part by default
self.assertEqual(4, len(response.data))
_part.active = False
_part.save()
response = self.get(url, data=query_data)
# As the part is not active, only 3 panels left
self.assertEqual(3, len(response.data))
# Disable the "ENABLE_PART_PANELS" setting, and try again
plugin.set_setting('ENABLE_PART_PANELS', False)
response = self.get(url, data=query_data)
# There should still be 3 panels
self.assertEqual(3, len(response.data))
# Check for the correct panel names
self.assertEqual(response.data[0]['name'], 'sample_panel')
self.assertIn('content', response.data[0])
self.assertNotIn('source', response.data[0])
self.assertEqual(response.data[1]['name'], 'broken_panel')
self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js')
self.assertNotIn('content', response.data[1])
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js')
self.assertNotIn('content', response.data[2])
# Next, disable the global setting for UI integration
InvenTreeSetting.set_setting(
'ENABLE_PLUGINS_INTERFACE', False, change_user=None
)
response = self.get(url, data=query_data)
# There should be no panels available
self.assertEqual(0, len(response.data))
# Set the setting back to True for subsequent tests
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)

View File

@ -0,0 +1,94 @@
"""API for UI plugins."""
from django.urls import path
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
import plugin.base.ui.serializers as UIPluginSerializers
from common.settings import get_global_setting
from InvenTree.exceptions import log_error
from plugin import registry
class PluginPanelList(APIView):
"""API endpoint for listing all available plugin panels."""
permission_classes = [IsAuthenticated]
serializer_class = UIPluginSerializers.PluginPanelSerializer
@extend_schema(
responses={200: UIPluginSerializers.PluginPanelSerializer(many=True)}
)
def get(self, request):
"""Show available plugin panels."""
target_model = request.query_params.get('target_model', None)
target_id = request.query_params.get('target_id', None)
panels = []
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# Extract all plugins from the registry which provide custom panels
for _plugin in registry.with_mixin('ui', active=True):
try:
# Allow plugins to fill this data out
plugin_panels = _plugin.get_ui_panels(
target_model, target_id, request
)
if plugin_panels and type(plugin_panels) is list:
for panel in plugin_panels:
panel['plugin'] = _plugin.slug
# TODO: Validate each panel before inserting
panels.append(panel)
except Exception:
# Custom panels could not load
# Log the error and continue
log_error(f'{_plugin.slug}.get_ui_panels')
return Response(
UIPluginSerializers.PluginPanelSerializer(panels, many=True).data
)
class PluginUIFeatureList(APIView):
"""API endpoint for listing all available plugin ui features."""
permission_classes = [IsAuthenticated]
serializer_class = UIPluginSerializers.PluginUIFeatureSerializer
@extend_schema(
responses={200: UIPluginSerializers.PluginUIFeatureSerializer(many=True)}
)
def get(self, request, feature):
"""Show available plugin ui features."""
features = []
if get_global_setting('ENABLE_PLUGINS_INTERFACE'):
# Extract all plugins from the registry which provide custom ui features
for _plugin in registry.with_mixin('ui', active=True):
# Allow plugins to fill this data out
plugin_features = _plugin.get_ui_features(
feature, request.query_params, request
)
if plugin_features and type(plugin_features) is list:
for _feature in plugin_features:
features.append(_feature)
return Response(
UIPluginSerializers.PluginUIFeatureSerializer(features, many=True).data
)
ui_plugins_api_urls = [
path('panels/', PluginPanelList.as_view(), name='api-plugin-panel-list'),
path(
'features/<str:feature>/',
PluginUIFeatureList.as_view(),
name='api-plugin-ui-feature-list',
),
]

View File

@ -4,7 +4,7 @@ Allows integration of custom UI elements into the React user interface.
"""
import logging
from typing import TypedDict
from typing import Literal, TypedDict
from rest_framework.request import Request
@ -29,6 +29,23 @@ class CustomPanel(TypedDict):
source: str
FeatureType = Literal['template_editor', 'template_preview']
class UIFeature(TypedDict):
"""Base type definition for a ui feature.
Attributes:
feature_type: The feature type (required, see documentation for all available types)
options: Feature options (required, see documentation for all available options for each type)
source: The source of the feature (required, path to a JavaScript file).
"""
feature_type: FeatureType
options: dict
source: str
class UserInterfaceMixin:
"""Plugin mixin class which handles injection of custom elements into the front-end interface.
@ -48,7 +65,7 @@ class UserInterfaceMixin:
def __init__(self):
"""Register mixin."""
super().__init__()
self.add_mixin('ui', True, __class__)
self.add_mixin('ui', True, __class__) # type: ignore
def get_ui_panels(
self, instance_type: str, instance_id: int, request: Request, **kwargs
@ -78,3 +95,19 @@ class UserInterfaceMixin:
"""
# Default implementation returns an empty list
return []
def get_ui_features(
self, feature_type: FeatureType, context: dict, request: Request
) -> list[UIFeature]:
"""Return a list of custom features to be injected into the UI.
Arguments:
feature_type: The type of feature being requested
context: Additional context data provided by the UI
request: HTTPRequest object (including user information)
Returns:
list: A list of custom UIFeature dicts to be injected into the UI
"""
# Default implementation returns an empty list
return []

View File

@ -0,0 +1,68 @@
"""Serializers for UI plugin api."""
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
class PluginPanelSerializer(serializers.Serializer):
"""Serializer for a plugin panel."""
class Meta:
"""Meta for serializer."""
fields = [
'plugin',
'name',
'label',
# Following fields are optional
'icon',
'content',
'source',
]
# Required fields
plugin = serializers.CharField(
label=_('Plugin Key'), required=True, allow_blank=False
)
name = serializers.CharField(
label=_('Panel Name'), required=True, allow_blank=False
)
label = serializers.CharField(
label=_('Panel Title'), required=True, allow_blank=False
)
# Optional fields
icon = serializers.CharField(
label=_('Panel Icon'), required=False, allow_blank=True
)
content = serializers.CharField(
label=_('Panel Content (HTML)'), required=False, allow_blank=True
)
source = serializers.CharField(
label=_('Panel Source (javascript)'), required=False, allow_blank=True
)
class PluginUIFeatureSerializer(serializers.Serializer):
"""Serializer for a plugin ui feature."""
class Meta:
"""Meta for serializer."""
fields = ['feature_type', 'options', 'source']
# Required fields
feature_type = serializers.CharField(
label=_('Feature Type'), required=True, allow_blank=False
)
options = serializers.DictField(label=_('Feature Options'), required=True)
source = serializers.CharField(
label=_('Feature Source (javascript)'), required=True, allow_blank=False
)

View File

@ -0,0 +1,157 @@
"""Unit tests for base mixins for plugins."""
from django.urls import reverse
from common.models import InvenTreeSetting
from InvenTree.unit_test import InvenTreeAPITestCase
from plugin.registry import registry
class UserInterfaceMixinTests(InvenTreeAPITestCase):
"""Test the UserInterfaceMixin plugin mixin class."""
roles = 'all'
fixtures = ['part', 'category', 'location', 'stock']
@classmethod
def setUpTestData(cls):
"""Set up the test case."""
super().setUpTestData()
# Ensure that the 'sampleui' plugin is installed and active
registry.set_plugin_state('sampleui', True)
# Ensure that UI plugins are enabled
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
def test_installed(self):
"""Test that the sample UI plugin is installed and active."""
plugin = registry.get_plugin('sampleui')
self.assertTrue(plugin.is_active())
plugins = registry.with_mixin('ui')
self.assertGreater(len(plugins), 0)
def test_panels(self):
"""Test that the sample UI plugin provides custom panels."""
from part.models import Part
plugin = registry.get_plugin('sampleui')
_part = Part.objects.first()
# Ensure that the part is active
_part.active = True
_part.save()
url = reverse('api-plugin-panel-list')
query_data = {'target_model': 'part', 'target_id': _part.pk}
# Enable *all* plugin settings
plugin.set_setting('ENABLE_PART_PANELS', True)
plugin.set_setting('ENABLE_PURCHASE_ORDER_PANELS', True)
plugin.set_setting('ENABLE_BROKEN_PANELS', True)
plugin.set_setting('ENABLE_DYNAMIC_PANEL', True)
# Request custom panel information for a part instance
response = self.get(url, data=query_data)
# There should be 4 active panels for the part by default
self.assertEqual(4, len(response.data))
_part.active = False
_part.save()
response = self.get(url, data=query_data)
# As the part is not active, only 3 panels left
self.assertEqual(3, len(response.data))
# Disable the "ENABLE_PART_PANELS" setting, and try again
plugin.set_setting('ENABLE_PART_PANELS', False)
response = self.get(url, data=query_data)
# There should still be 3 panels
self.assertEqual(3, len(response.data))
# Check for the correct panel names
self.assertEqual(response.data[0]['name'], 'sample_panel')
self.assertIn('content', response.data[0])
self.assertNotIn('source', response.data[0])
self.assertEqual(response.data[1]['name'], 'broken_panel')
self.assertEqual(response.data[1]['source'], '/this/does/not/exist.js')
self.assertNotIn('content', response.data[1])
self.assertEqual(response.data[2]['name'], 'dynamic_panel')
self.assertEqual(response.data[2]['source'], '/static/plugin/sample_panel.js')
self.assertNotIn('content', response.data[2])
# Next, disable the global setting for UI integration
InvenTreeSetting.set_setting(
'ENABLE_PLUGINS_INTERFACE', False, change_user=None
)
response = self.get(url, data=query_data)
# There should be no panels available
self.assertEqual(0, len(response.data))
# Set the setting back to True for subsequent tests
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)
def test_ui_features(self):
"""Test that the sample UI plugin provides custom features."""
template_editor_url = reverse(
'api-plugin-ui-feature-list', kwargs={'feature': 'template_editor'}
)
template_preview_url = reverse(
'api-plugin-ui-feature-list', kwargs={'feature': 'template_preview'}
)
query_data_label = {'template_type': 'labeltemplate', 'template_model': 'part'}
query_data_report = {
'template_type': 'reporttemplate',
'template_model': 'part',
}
# Request custom template editor information
response = self.get(template_editor_url, data=query_data_label)
self.assertEqual(1, len(response.data))
response = self.get(template_editor_url, data=query_data_report)
self.assertEqual(0, len(response.data))
response = self.get(template_preview_url, data=query_data_report)
self.assertEqual(1, len(response.data))
# Check for the correct feature details here
self.assertEqual(response.data[0]['feature_type'], 'template_preview')
self.assertDictEqual(
response.data[0]['options'],
{
'key': 'sample-template-preview',
'title': 'Sample Template Preview',
'icon': 'category',
},
)
self.assertEqual(
response.data[0]['source'],
'/static/plugin/sample_template.js:getTemplatePreview',
)
# Next, disable the global setting for UI integration
InvenTreeSetting.set_setting(
'ENABLE_PLUGINS_INTERFACE', False, change_user=None
)
response = self.get(template_editor_url, data=query_data_label)
# There should be no features available
self.assertEqual(0, len(response.data))
# Set the setting back to True for subsequent tests
InvenTreeSetting.set_setting('ENABLE_PLUGINS_INTERFACE', True, change_user=None)

View File

@ -14,10 +14,10 @@ from plugin.base.integration.ReportMixin import ReportMixin
from plugin.base.integration.ScheduleMixin import ScheduleMixin
from plugin.base.integration.SettingsMixin import SettingsMixin
from plugin.base.integration.UrlsMixin import UrlsMixin
from plugin.base.integration.UserInterfaceMixin import UserInterfaceMixin
from plugin.base.integration.ValidationMixin import ValidationMixin
from plugin.base.label.mixins import LabelPrintingMixin
from plugin.base.locate.mixins import LocateMixin
from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [
'APICallMixin',

View File

@ -122,3 +122,36 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
})
return panels
def get_ui_features(self, feature_type, context, request):
"""Return a list of custom features to be injected into the UI."""
if (
feature_type == 'template_editor'
and context.get('template_type') == 'labeltemplate'
):
return [
{
'feature_type': 'template_editor',
'options': {
'key': 'sample-template-editor',
'title': 'Sample Template Editor',
'icon': 'keywords',
},
'source': '/static/plugin/sample_template.js:getTemplateEditor',
}
]
if feature_type == 'template_preview':
return [
{
'feature_type': 'template_preview',
'options': {
'key': 'sample-template-preview',
'title': 'Sample Template Preview',
'icon': 'category',
},
'source': '/static/plugin/sample_template.js:getTemplatePreview',
}
]
return []

View File

@ -0,0 +1,33 @@
export function getTemplateEditor({ featureContext, pluginContext }) {
const { ref } = featureContext;
console.log("Template editor feature was called with", featureContext, pluginContext);
const t = document.createElement("textarea");
t.id = 'sample-template-editor-textarea';
t.rows = 25;
t.cols = 60;
featureContext.registerHandlers({
setCode: (code) => {
t.value = code;
},
getCode: () => {
return t.value;
}
});
ref.innerHTML = "";
ref.appendChild(t);
}
export function getTemplatePreview({ featureContext, pluginContext }) {
const { ref } = featureContext;
console.log("Template preview feature was called with", featureContext, pluginContext);
featureContext.registerHandlers({
updatePreview: (...args) => {
console.log("updatePreview", args);
}
});
ref.innerHTML = "<h1>Hello world</h1>";
}

View File

@ -301,46 +301,3 @@ class PluginRelationSerializer(serializers.PrimaryKeyRelatedField):
def to_representation(self, value):
"""Return the 'key' of the PluginConfig object."""
return value.key
class PluginPanelSerializer(serializers.Serializer):
"""Serializer for a plugin panel."""
class Meta:
"""Meta for serializer."""
fields = [
'plugin',
'name',
'label',
# Following fields are optional
'icon',
'content',
'source',
]
# Required fields
plugin = serializers.CharField(
label=_('Plugin Key'), required=True, allow_blank=False
)
name = serializers.CharField(
label=_('Panel Name'), required=True, allow_blank=False
)
label = serializers.CharField(
label=_('Panel Title'), required=True, allow_blank=False
)
# Optional fields
icon = serializers.CharField(
label=_('Panel Icon'), required=False, allow_blank=True
)
content = serializers.CharField(
label=_('Panel Content (HTML)'), required=False, allow_blank=True
)
source = serializers.CharField(
label=_('Panel Source (javascript)'), required=False, allow_blank=True
)