Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI plugins custom features #8137

Merged
merged 24 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e235464
initial implementation to let plugins provide custom ui features
wolflu05 Sep 17, 2024
0918fd8
Merge remote-tracking branch 'upstream/master' into pui-feature-plugin
wolflu05 Sep 18, 2024
15962e2
provide exportable types
wolflu05 Sep 18, 2024
ca17a07
refactor ref into renderContext to make it more generic and support t…
wolflu05 Sep 18, 2024
5ec5aa7
rename 'renderContext' -> 'featureContext' as not all features may re…
wolflu05 Sep 18, 2024
a72d845
allow to specify the function name via the source file string divided…
wolflu05 Sep 18, 2024
4fb9461
Bump api version
wolflu05 Sep 18, 2024
498c7ea
add tests
wolflu05 Sep 18, 2024
2e184ff
add docs
wolflu05 Sep 18, 2024
6959c8c
add docs
wolflu05 Sep 18, 2024
ce3dd4a
debug: workflow
wolflu05 Sep 18, 2024
2051baf
debug: workflow
wolflu05 Sep 18, 2024
b7aa82d
fix tests
wolflu05 Sep 18, 2024
1c11658
fix tests hopefully
wolflu05 Sep 18, 2024
655c958
apply suggestions from codereview
wolflu05 Sep 19, 2024
bda4b0a
Merge remote-tracking branch 'upstream/master' into pui-feature-plugin
wolflu05 Sep 19, 2024
bc79eeb
Merge remote-tracking branch 'upstream/master' into pui-feature-plugin
wolflu05 Sep 19, 2024
d59ce49
trigger: ci
wolflu05 Sep 19, 2024
7022f1d
Merge branch 'master' into pui-feature-plugin
wolflu05 Sep 21, 2024
7ccec4b
Merge branch 'master' into pui-feature-plugin
wolflu05 Sep 23, 2024
93ae09c
Merge branch 'master' into pui-feature-plugin
wolflu05 Sep 24, 2024
920c58e
Prove that coverage does not work
wolflu05 Sep 26, 2024
91ad6da
Revert "Prove that coverage does not work"
wolflu05 Sep 26, 2024
ebe2f11
potentially fix test???
wolflu05 Sep 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion docs/docs/extend/plugins/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ When rendering certain content in the user interface, the rendering functions ar

Many of the pages in the InvenTree web interface are built using a series of "panels" which are displayed on the page. Custom panels can be added to these pages, by implementing the `get_ui_panels` method:

::: plugin.base.integration.UserInterfaceMixin.UserInterfaceMixin.get_ui_panels
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_panels
options:
show_bases: False
show_root_heading: False
Expand Down Expand Up @@ -89,6 +89,46 @@ export function isPanelHidden(context) {
}
```

## Custom UI Functions

User interface plugins can also provide additional user interface functions. These functions can be provided via the `get_ui_features` method:

::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_features
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_sources: True
summary: False
members: []

::: plugin.samples.integration.user_interface_sample.SampleUserInterfacePlugin.get_ui_features
options:
show_bases: False
show_root_heading: False
show_root_toc_entry: False
show_source: True
members: []


Currently the following functions can be extended:

### Template editors

The `template_editor` feature type can be used to provide custom template editors.

**Example:**

{{ includefile("src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js", title="sample_template.js", fmt="javascript") }}

### Template previews

The `template_preview` feature type can be used to provide custom template previews. For an example see:

**Example:**

{{ includefile("src/backend/InvenTree/plugin/samples/static/plugin/sample_template.js", title="sample_template.js", fmt="javascript") }}

## Sample Plugin

A sample plugin which implements custom user interface functionality is provided in the InvenTree source code:
Expand Down
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
58 changes: 3 additions & 55 deletions src/backend/InvenTree/plugin/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,12 @@
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,
Expand All @@ -33,6 +30,7 @@
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

Expand Down Expand Up @@ -417,43 +415,6 @@ def get(self, request):
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."""

Expand All @@ -468,21 +429,8 @@ class PluginMetadataView(MetadataView):
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'),
Expand Down
100 changes: 1 addition & 99 deletions src/backend/InvenTree/plugin/base/integration/test_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

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
Expand Down Expand Up @@ -479,100 +478,3 @@ class Wrong(PanelMixin, InvenTreePlugin):

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)
Empty file.
94 changes: 94 additions & 0 deletions src/backend/InvenTree/plugin/base/ui/api.py
Original file line number Diff line number Diff line change
@@ -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',
),
]
Loading
Loading