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:
@ -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
|
||||
|
||||
|
@ -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'),
|
||||
]),
|
||||
),
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'}
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
@ -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."""
|
||||
|
||||
|
Reference in New Issue
Block a user