2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +00:00

[PUI] Plugin settings UI (#8228)

* Visual tweaks for admin pages

* Provide admin js file via API

* Backend fixes

* Tweak error detail drawer

* Refactor plugin detail panel

- Split out into separate files
- Use <Accordion />
- Display custom configuration (if available)

* Refactoring

* Add custom configuration to sample UI plugin

* Bump API version

* Add separate API endpoint for admin integration details

* Refactor plugin drawer

* Null check

* Add playwright tests for custom admin integration

* Enable plugin panels in "settings" pages

* Fix for unit test

* Hide "Plugin Settings" for plugin without "settings" mixin

* Fixes for playwright tests

* Update playwright tests

* Improved error message
This commit is contained in:
Oliver
2024-10-07 22:25:56 +11:00
committed by GitHub
parent 36e3159c1a
commit 798e25a9dc
26 changed files with 540 additions and 242 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 264
INVENTREE_API_VERSION = 265
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
265 - 2024-10-06 : https://github.com/inventree/InvenTree/pull/8228
- Adds API endpoint for providing custom admin integration details for plugins
264 - 2024-10-03 : https://github.com/inventree/InvenTree/pull/8231
- Adds Sales Order Shipment attachment model type

View File

@ -21,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (
CreateAPI,
ListAPI,
RetrieveAPI,
RetrieveDestroyAPI,
RetrieveUpdateAPI,
UpdateAPI,
@ -177,6 +178,18 @@ class PluginDetail(RetrieveDestroyAPI):
return super().delete(request, *args, **kwargs)
class PluginAdminDetail(RetrieveAPI):
"""Endpoint for viewing admin integration plugin details.
This endpoint is used to view the available admin integration options for a plugin.
"""
queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginAdminDetailSerializer
lookup_field = 'key'
lookup_url_kwarg = 'plugin'
class PluginInstall(CreateAPI):
"""Endpoint for installing a new plugin."""
@ -484,6 +497,9 @@ plugin_api_urls = [
PluginUninstall.as_view(),
name='api-plugin-uninstall',
),
path(
'admin/', PluginAdminDetail.as_view(), name='api-plugin-admin'
),
path('', PluginDetail.as_view(), name='api-plugin-detail'),
]),
),

View File

@ -53,10 +53,6 @@ class UserInterfaceMixin:
- All content is accessed via the API, as requested by the user interface.
- This means that content can be dynamically generated, based on the current state of the system.
The following custom UI methods are available:
- get_ui_panels: Return a list of custom panels to be injected into the UI
"""
class MixinMeta:

View File

@ -87,7 +87,9 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
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.assertEqual(
response.data[2]['source'], '/static/plugins/sampleui/sample_panel.js'
)
self.assertNotIn('content', response.data[2])
# Next, disable the global setting for UI integration

View File

@ -187,6 +187,43 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
return getattr(self.plugin, 'is_package', False)
@property
def admin_source(self) -> str:
"""Return the path to the javascript file which renders custom admin content for this plugin.
- It is required that the file provides a 'renderPluginSettings' function!
"""
if not self.plugin:
return None
if not self.is_installed() or not self.active:
return None
if hasattr(self.plugin, 'get_admin_source'):
try:
return self.plugin.get_admin_source()
except Exception:
pass
return None
@property
def admin_context(self) -> dict:
"""Return the context data for the admin integration."""
if not self.plugin:
return None
if not self.is_installed() or not self.active:
return None
if hasattr(self.plugin, 'get_admin_context'):
try:
return self.plugin.get_admin_context()
except Exception:
pass
return {}
def activate(self, active: bool) -> None:
"""Set the 'active' status of this plugin instance."""
from InvenTree.tasks import check_for_migrations, offload_task

View File

@ -220,6 +220,10 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
WEBSITE = None
LICENSE = None
# Optional path to a JavaScript file which will be loaded in the admin panel
# This file must provide a function called renderPluginSettings
ADMIN_SOURCE = None
def __init__(self):
"""Init a plugin.
@ -445,4 +449,26 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
from django.conf import settings
return '/' + os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)
url = os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args)
if not url.startswith('/'):
url = '/' + url
return url
def get_admin_source(self) -> str:
"""Return a path to a JavaScript file which contains custom UI settings.
The frontend code expects that this file provides a function named 'renderPluginSettings'.
"""
if not self.ADMIN_SOURCE:
return None
return self.plugin_static_file(self.ADMIN_SOURCE)
def get_admin_context(self) -> dict:
"""Return a context dictionary for the admin panel settings.
This is an optional method which can be overridden by the plugin.
"""
return None

View File

@ -21,6 +21,8 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
VERSION = '1.1'
ADMIN_SOURCE = 'ui_settings.js'
SETTINGS = {
'ENABLE_PART_PANELS': {
'name': _('Enable Part Panels'),
@ -77,7 +79,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
})
# A broken panel which tries to load a non-existent JS file
if self.get_setting('ENABLE_BROKEN_PANElS'):
if instance_id is not None and self.get_setting('ENABLE_BROKEN_PANElS'):
panels.append({
'name': 'broken_panel',
'label': 'Broken Panel',
@ -90,7 +92,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
panels.append({
'name': 'dynamic_panel',
'label': 'Dynamic Part Panel',
'source': '/static/plugin/sample_panel.js',
'source': self.plugin_static_file('sample_panel.js'),
'context': {
'version': INVENTREE_SW_VERSION,
'plugin_version': self.VERSION,
@ -166,3 +168,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
]
return []
def get_admin_context(self) -> dict:
"""Return custom context data which can be rendered in the admin panel."""
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}

View File

@ -0,0 +1,16 @@
export function renderPluginSettings(target, data) {
console.log("renderPluginSettings:", data);
target.innerHTML = `
<h4>Custom Plugin Configuration Content</h4>
<p>Custom plugin configuration UI elements can be rendered here.</p>
<p>The following context data was provided by the server:</p>
<ul>
${Object.entries(data.context).map(([key, value]) => `<li>${key}: ${value}</li>`).join('')}
</ul>
`;
}

View File

@ -67,6 +67,31 @@ class PluginConfigSerializer(serializers.ModelSerializer):
mixins = serializers.DictField(read_only=True)
class PluginAdminDetailSerializer(serializers.ModelSerializer):
"""Serializer for a PluginConfig with admin details."""
class Meta:
"""Metaclass options for serializer."""
model = PluginConfig
fields = ['source', 'context']
source = serializers.CharField(
allow_null=True,
label=_('Source File'),
help_text=_('Path to the source file for admin integration'),
source='admin_source',
)
context = serializers.JSONField(
allow_null=True,
label=_('Context'),
help_text=_('Optional context data for the admin integration'),
source='admin_context',
)
class PluginConfigInstallSerializer(serializers.Serializer):
"""Serializer for installing a new plugin."""