Merge pull request #2937 from SchrodingersGat/panel-plugin

[Feature] UI Plugins
This commit is contained in:
Oliver 2022-05-08 09:01:43 +10:00 committed by GitHub
commit 64e2234159
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 429 additions and 70 deletions

View File

@ -131,7 +131,7 @@ def round_decimal(value, places):
class RoundingDecimalFormField(forms.DecimalField):
def to_python(self, value):
value = super(RoundingDecimalFormField, self).to_python(value)
value = super().to_python(value)
value = round_decimal(value, self.decimal_places)
return value
@ -149,7 +149,7 @@ class RoundingDecimalFormField(forms.DecimalField):
class RoundingDecimalField(models.DecimalField):
def to_python(self, value):
value = super(RoundingDecimalField, self).to_python(value)
value = super().to_python(value)
return round_decimal(value, self.decimal_places)
def formfield(self, **kwargs):

View File

@ -58,7 +58,7 @@ class HelperForm(forms.ModelForm):
def is_valid(self):
valid = super(HelperForm, self).is_valid()
valid = super().is_valid()
return valid

View File

@ -343,7 +343,7 @@ TEMPLATES = [
],
'loaders': [(
'django.template.loaders.cached.Loader', [
'plugin.loader.PluginTemplateLoader',
'plugin.template.PluginTemplateLoader',
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
])

View File

@ -153,28 +153,24 @@ backendpatterns = [
]
frontendpatterns = [
re_path(r'^part/', include(part_urls)),
re_path(r'^manufacturer-part/', include(manufacturer_part_urls)),
re_path(r'^supplier-part/', include(supplier_part_urls)),
# Apps
re_path(r'^build/', include(build_urls)),
re_path(r'^common/', include(common_urls)),
re_path(r'^stock/', include(stock_urls)),
re_path(r'^company/', include(company_urls)),
re_path(r'^order/', include(order_urls)),
re_path(r'^build/', include(build_urls)),
re_path(r'^settings/', include(settings_urls)),
re_path(r'^notifications/', include(notifications_urls)),
re_path(r'^manufacturer-part/', include(manufacturer_part_urls)),
re_path(r'^part/', include(part_urls)),
re_path(r'^stock/', include(stock_urls)),
re_path(r'^supplier-part/', include(supplier_part_urls)),
re_path(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
re_path(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
re_path(r'^index/', IndexView.as_view(), name='index'),
re_path(r'^notifications/', include(notifications_urls)),
re_path(r'^search/', SearchView.as_view(), name='search'),
re_path(r'^settings/', include(settings_urls)),
re_path(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
# admin sites

View File

@ -654,7 +654,7 @@ class IndexView(TemplateView):
def get_context_data(self, **kwargs):
context = super(TemplateView, self).get_context_data(**kwargs)
context = super().get_context_data(**kwargs)
return context
@ -849,7 +849,7 @@ class SettingCategorySelectView(FormView):
def get_initial(self):
""" Set category selection """
initial = super(SettingCategorySelectView, self).get_initial()
initial = super().get_initial()
category = self.request.GET.get('category', None)
if category:

View File

@ -14,6 +14,8 @@ from InvenTree.views import AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import BuildStatus
from plugin.views import InvenTreePluginViewMixin
class BuildIndex(InvenTreeRoleMixin, ListView):
"""
@ -29,7 +31,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
def get_context_data(self, **kwargs):
context = super(BuildIndex, self).get_context_data(**kwargs).copy()
context = super().get_context_data(**kwargs)
context['BuildStatus'] = BuildStatus
@ -41,7 +43,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
return context
class BuildDetail(InvenTreeRoleMixin, DetailView):
class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""
Detail view of a single Build object.
"""
@ -52,7 +54,7 @@ class BuildDetail(InvenTreeRoleMixin, DetailView):
def get_context_data(self, **kwargs):
ctx = super(DetailView, self).get_context_data(**kwargs)
ctx = super().get_context_data(**kwargs)
build = self.get_object()

View File

@ -23,9 +23,10 @@ from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart
from .forms import CompanyImageDownloadForm
from plugin.views import InvenTreePluginViewMixin
class CompanyIndex(InvenTreeRoleMixin, ListView):
""" View for displaying list of companies
@ -104,7 +105,7 @@ class CompanyIndex(InvenTreeRoleMixin, ListView):
return queryset
class CompanyDetail(DetailView):
class CompanyDetail(InvenTreePluginViewMixin, DetailView):
""" Detail view for Company object """
context_obect_name = 'company'
template_name = 'company/detail.html'
@ -196,7 +197,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
)
class ManufacturerPartDetail(DetailView):
class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView):
""" Detail view for ManufacturerPart """
model = ManufacturerPart
template_name = 'company/manufacturer_part_detail.html'
@ -210,7 +211,7 @@ class ManufacturerPartDetail(DetailView):
return ctx
class SupplierPartDetail(DetailView):
class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
""" Detail view for SupplierPart """
model = SupplierPart
template_name = 'company/supplier_part_detail.html'

View File

@ -33,6 +33,8 @@ from part.views import PartPricing
from InvenTree.helpers import DownloadFile
from InvenTree.views import InvenTreeRoleMixin, AjaxView
from plugin.views import InvenTreePluginViewMixin
logger = logging.getLogger("inventree")
@ -65,7 +67,7 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
context_object_name = 'orders'
class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
""" Detail view for a PurchaseOrder object """
context_object_name = 'order'
@ -78,7 +80,7 @@ class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
return ctx
class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
""" Detail view for a SalesOrder object """
context_object_name = 'order'

View File

@ -19,7 +19,7 @@ from .templatetags import inventree_extras
import part.settings
from InvenTree import version
from common.models import InvenTreeSetting, NotificationEntry, NotificationMessage
from common.models import InvenTreeSetting, InvenTreeUserSetting, NotificationEntry, NotificationMessage
from common.notifications import storage, UIMessageNotification
@ -87,7 +87,7 @@ class TemplateTagTest(TestCase):
def test_user_settings(self):
result = inventree_extras.user_settings(self.user)
self.assertEqual(len(result), 36)
self.assertEqual(len(result), len(InvenTreeUserSetting.SETTINGS))
def test_global_settings(self):
result = inventree_extras.global_settings()

View File

@ -53,6 +53,8 @@ from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool
from plugin.views import InvenTreePluginViewMixin
class PartIndex(InvenTreeRoleMixin, ListView):
""" View for displaying list of Part objects
@ -67,7 +69,7 @@ class PartIndex(InvenTreeRoleMixin, ListView):
def get_context_data(self, **kwargs):
context = super(PartIndex, self).get_context_data(**kwargs).copy()
context = super().get_context_data(**kwargs).copy()
# View top-level categories
children = PartCategory.objects.filter(parent=None)
@ -365,7 +367,7 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
return PartImport.validate(self, self.steps.current, form, **kwargs)
class PartDetail(InvenTreeRoleMixin, DetailView):
class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
""" Detail view for Part object
"""
@ -969,7 +971,7 @@ class PartParameterTemplateDelete(AjaxDeleteView):
ajax_form_title = _("Delete Part Parameter Template")
class CategoryDetail(InvenTreeRoleMixin, DetailView):
class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
""" Detail view for PartCategory """
model = PartCategory
@ -979,7 +981,7 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
def get_context_data(self, **kwargs):
context = super(CategoryDetail, self).get_context_data(**kwargs).copy()
context = super().get_context_data(**kwargs).copy()
try:
context['part_count'] = kwargs['object'].partcount()
@ -1045,7 +1047,7 @@ class CategoryParameterTemplateCreate(AjaxCreateView):
- Display parameter templates which are not yet related
"""
form = super(AjaxCreateView, self).get_form()
form = super().get_form()
form.fields['category'].widget = HiddenInput()
@ -1140,7 +1142,7 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
- Display parameter templates which are not yet related
"""
form = super(AjaxUpdateView, self).get_form()
form = super().get_form()
form.fields['category'].widget = HiddenInput()
form.fields['add_to_all_categories'].widget = HiddenInput()

View File

@ -9,9 +9,12 @@ import requests
from django.urls import include, re_path
from django.db.utils import OperationalError, ProgrammingError
from plugin.models import PluginConfig, PluginSetting
from plugin.urls import PLUGIN_BASE
import InvenTree.helpers
from plugin.helpers import MixinImplementationError, MixinNotImplementedError
from plugin.models import PluginConfig, PluginSetting
from plugin.template import render_template
from plugin.urls import PLUGIN_BASE
logger = logging.getLogger('inventree')
@ -542,3 +545,120 @@ class APICallMixin:
if simple_response:
return response.json()
return response
class PanelMixin:
"""
Mixin which allows integration of custom 'panels' into a particular page.
The mixin provides a number of key functionalities:
- Adds an (initially hidden) panel to the page
- Allows rendering of custom templated content to the panel
- Adds a menu item to the 'navbar' on the left side of the screen
- Allows custom javascript to be run when the panel is initially loaded
The PanelMixin class allows multiple panels to be returned for any page,
and also allows the plugin to return panels for many different pages.
Any class implementing this mixin must provide the 'get_custom_panels' method,
which dynamically returns the custom panels for a particular page.
This method is provided with:
- view : The View object which is being rendered
- request : The HTTPRequest object
Note that as this is called dynamically (per request),
then the actual panels returned can vary depending on the particular request or page
The 'get_custom_panels' method must return a list of dict objects, each with the following keys:
- title : The title of the panel, to appear in the sidebar menu
- description : Extra descriptive text (optional)
- icon : The icon to appear in the sidebar menu
- content : The HTML content to appear in the panel, OR
- content_template : A template file which will be rendered to produce the panel content
- javascript : The javascript content to be rendered when the panel is loade, OR
- javascript_template : A template file which will be rendered to produce javascript
e.g.
{
'title': "Updates",
'description': "Latest updates for this part",
'javascript': 'alert("You just loaded this panel!")',
'content': '<b>Hello world</b>',
}
"""
class MixinMeta:
MIXIN_NAME = 'Panel'
def __init__(self):
super().__init__()
self.add_mixin('panel', True, __class__)
def get_custom_panels(self, view, request):
""" This method *must* be implemented by the plugin class """
raise NotImplementedError(f"{__class__} is missing the 'get_custom_panels' method")
def get_panel_context(self, view, request, context):
"""
Build the context data to be used for template rendering.
Custom class can override this to provide any custom context data.
(See the example in "custom_panel_sample.py")
"""
# Provide some standard context items to the template for rendering
context['plugin'] = self
context['request'] = request
context['user'] = getattr(request, 'user', None)
context['view'] = view
try:
context['object'] = view.get_object()
except AttributeError:
pass
return context
def render_panels(self, view, request, context):
panels = []
# Construct an updated context object for template rendering
ctx = self.get_panel_context(view, request, context)
for panel in self.get_custom_panels(view, request):
content_template = panel.get('content_template', None)
javascript_template = panel.get('javascript_template', None)
if content_template:
# Render content template to HTML
panel['content'] = render_template(self, content_template, ctx)
if javascript_template:
# Render javascript template to HTML
panel['javascript'] = render_template(self, javascript_template, ctx)
# Check for required keys
required_keys = ['title', 'content']
if any([key not in panel for key in required_keys]):
logger.warning(f"Custom panel for plugin {__class__} is missing a required parameter")
continue
# Add some information on this plugin
panel['plugin'] = self
panel['slug'] = self.slug
# Add a 'key' for the panel, which is mostly guaranteed to be unique
panel['key'] = InvenTree.helpers.generateTestKey(self.slug + panel.get('title', 'panel'))
panels.append(panel)
return panels

View File

@ -1,19 +0,0 @@
"""
load templates for loaded plugins
"""
from django.template.loaders.filesystem import Loader as FilesystemLoader
from pathlib import Path
from plugin import registry
class PluginTemplateLoader(FilesystemLoader):
def get_dirs(self):
dirname = 'templates'
template_dirs = []
for plugin in registry.plugins.values():
new_path = Path(plugin.path) / dirname
if Path(new_path).is_dir():
template_dirs.append(new_path) # pragma: no cover
return tuple(template_dirs)

View File

@ -2,7 +2,8 @@
Utility class to enable simpler imports
"""
from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin, PanelMixin
from common.notifications import SingleNotificationMethod, BulkNotificationMethod
from ..builtin.action.mixins import ActionMixin
@ -17,6 +18,7 @@ __all__ = [
'ScheduleMixin',
'SettingsMixin',
'UrlsMixin',
'PanelMixin',
'ActionMixin',
'BarcodeMixin',
'SingleNotificationMethod',

View File

@ -307,14 +307,17 @@ class PluginsRegistry:
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
try:
plugin = plugin()
except Exception as error:
# log error and raise it -> disable plugin
handle_error(error, log_name='init')
logger.info(f'Loaded integration plugin {plugin.slug}')
logger.debug(f'Loaded integration plugin {plugin.PLUGIN_NAME}')
plugin.is_package = was_packaged
if plugin_db_setting:
plugin.pk = plugin_db_setting.pk

View File

@ -0,0 +1,94 @@
"""
Sample plugin which renders custom panels on certain pages
"""
from plugin import IntegrationPluginBase
from plugin.mixins import PanelMixin, SettingsMixin
from part.views import PartDetail
from stock.views import StockLocationDetail
class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
"""
A sample plugin which renders some custom panels.
"""
PLUGIN_NAME = "CustomPanelExample"
PLUGIN_SLUG = "panel"
PLUGIN_TITLE = "Custom Panel Example"
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
VERSION = "0.1"
SETTINGS = {
'ENABLE_HELLO_WORLD': {
'name': 'Hello World',
'description': 'Enable a custom hello world panel on every page',
'default': False,
'validator': bool,
}
}
def get_panel_context(self, view, request, context):
ctx = super().get_panel_context(view, request, context)
# If we are looking at a StockLocationDetail view, add location context object
if isinstance(view, StockLocationDetail):
ctx['location'] = view.get_object()
return ctx
def get_custom_panels(self, view, request):
"""
You can decide at run-time which custom panels you want to display!
- Display on every page
- Only on a single page or set of pages
- Only for a specific instance (e.g. part)
- Based on the user viewing the page!
"""
panels = [
{
# This panel will not be displayed, as it is missing the 'content' key
'title': 'No Content',
}
]
if self.get_setting('ENABLE_HELLO_WORLD'):
panels.append({
# This 'hello world' panel will be displayed on any view which implements custom panels
'title': 'Hello World',
'icon': 'fas fa-boxes',
'content': '<b>Hello world!</b>',
'description': 'A simple panel which renders hello world',
'javascript': 'console.log("Hello world, from a custom panel!");',
})
# This panel will *only* display on the PartDetail view
if isinstance(view, PartDetail):
panels.append({
'title': 'Custom Part Panel',
'icon': 'fas fa-shapes',
'content': '<em>This content only appears on the PartDetail page, you know!</em>',
})
# This panel will *only* display on the StockLocation view,
# and *only* if the StockLocation has *no* child locations
if isinstance(view, StockLocationDetail):
try:
loc = view.get_object()
if not loc.get_descendants(include_self=False).exists():
panels.append({
'title': 'Childless Location',
'icon': 'fa-user',
'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file!
})
except:
pass
return panels

View File

@ -0,0 +1,11 @@
<h4>Template Rendering</h4>
<div class='alert alert-block alert-info'>
This panel has been rendered using a template file!
</div>
<em>This location has no sublocations!</em>
<ul>
<li><b>Location Name</b>: {{ location.name }}</li>
<li><b>Location Path</b>: {{ location.pathstring }}</li>
</ul>

View File

@ -0,0 +1,62 @@
"""
load templates for loaded plugins
"""
import logging
from pathlib import Path
from django import template
from django.template.loaders.filesystem import Loader as FilesystemLoader
from plugin import registry
logger = logging.getLogger('inventree')
class PluginTemplateLoader(FilesystemLoader):
"""
A custom template loader which allows loading of templates from installed plugins.
Each plugin can register templates simply by providing a 'templates' directory in its root path.
The convention is that each 'templates' directory contains a subdirectory with the same name as the plugin,
e.g. templates/myplugin/my_template.html
In this case, the template can then be loaded (from any plugin!) by loading "myplugin/my_template.html".
The separate plugin-named directories help keep the templates separated and uniquely identifiable.
"""
def get_dirs(self):
dirname = 'templates'
template_dirs = []
for plugin in registry.plugins.values():
new_path = Path(plugin.path) / dirname
if Path(new_path).is_dir():
template_dirs.append(new_path)
return tuple(template_dirs)
def render_template(plugin, template_file, context=None):
"""
Locate and render a template file, available in the global template context.
"""
try:
tmp = template.loader.get_template(template_file)
except template.TemplateDoesNotExist:
logger.error(f"Plugin {plugin.slug} could not locate template '{template_file}'")
return f"""
<div class='alert alert-block alert-danger'>
Template file <em>{template_file}</em> does not exist.
</div>
"""
# Render with the provided context
html = tmp.render(context)
return html

40
InvenTree/plugin/views.py Normal file
View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from plugin.registry import registry
class InvenTreePluginViewMixin:
"""
Custom view mixin which adds context data to the view,
based on loaded plugins.
This allows rendered pages to be augmented by loaded plugins.
"""
def get_plugin_panels(self, ctx):
"""
Return a list of extra 'plugin panels' associated with this view
"""
panels = []
for plug in registry.with_mixin('panel'):
panels += plug.render_panels(self, self.request, ctx)
return panels
def get_context_data(self, **kwargs):
"""
Add plugin context data to the view
"""
ctx = super().get_context_data(**kwargs)
if settings.PLUGINS_ENABLED:
ctx['plugin_panels'] = self.get_plugin_panels(ctx)
return ctx

View File

@ -26,8 +26,10 @@ import common.settings
from . import forms as StockForms
from plugin.views import InvenTreePluginViewMixin
class StockIndex(InvenTreeRoleMixin, ListView):
class StockIndex(InvenTreeRoleMixin, InvenTreePluginViewMixin, ListView):
""" StockIndex view loads all StockLocation and StockItem object
"""
model = StockItem
@ -35,7 +37,7 @@ class StockIndex(InvenTreeRoleMixin, ListView):
context_obect_name = 'locations'
def get_context_data(self, **kwargs):
context = super(StockIndex, self).get_context_data(**kwargs).copy()
context = super().get_context_data(**kwargs).copy()
# Return all top-level locations
locations = StockLocation.objects.filter(parent=None)
@ -54,7 +56,7 @@ class StockIndex(InvenTreeRoleMixin, ListView):
return context
class StockLocationDetail(InvenTreeRoleMixin, DetailView):
class StockLocationDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""
Detailed view of a single StockLocation object
"""
@ -75,7 +77,7 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView):
return context
class StockItemDetail(InvenTreeRoleMixin, DetailView):
class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""
Detailed view of a single StockItem object
"""

View File

@ -69,6 +69,12 @@
<td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span>
{% define plugin.registered_mixins as mixin_list %}
{% if plugin.is_sample %}
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
<span class='badge bg-info rounded-pill badge-right'>{% trans "Sample" %}</span>
</a>
{% endif %}
{% if mixin_list %}
{% for mixin in mixin_list %}
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
@ -77,12 +83,6 @@
{% endfor %}
{% endif %}
{% if plugin.is_sample %}
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
<span class='badge bg-info rounded-pill'>{% trans "code sample" %}</span>
</a>
{% endif %}
{% if plugin.website %}
<a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
{% endif %}

View File

@ -83,6 +83,7 @@
{% block sidebar %}
<!-- Sidebar goes here -->
{% endblock %}
{% include "plugin/panel_menu.html" %}
{% include "sidebar_toggle.html" with target='sidebar' %}
</ul>
</div>
@ -209,6 +210,9 @@
<script type='text/javascript'>
$(document).ready(function () {
{% include "plugin/panel_js.html" %}
{% block js_ready %}
{% endblock %}

View File

@ -61,6 +61,8 @@
</div>
{% block page_content %}
<!-- Custom page content goes here-->
{% endblock %}
{% include "plugin/panel_content.html" %}
{% endblock %}

View File

@ -0,0 +1,19 @@
{% if plugin_panels %}
<!-- Custom panel items, loaded via plugins -->
{% for panel in plugin_panels %}
<div class='panel panel-hidden' id='panel-{{ panel.key }}'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{{ panel.title }}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<!-- TODO: Implement custom action buttons for plugin panels -->
</div>
</div>
</div>
<div class='panel-content'>
{{ panel.content | safe }}
</div>
</div>
{% endfor %}
{% endif %}

View File

@ -0,0 +1,10 @@
{% if plugin_panels %}
// Run custom javascript when plugin panels are loaded
{% for panel in plugin_panels %}
{% if panel.javascript %}
onPanelLoad('{{ panel.key }}', function() {
{{ panel.javascript | safe }}
});
{% endif %}
{% endfor %}
{% endif %}

View File

@ -0,0 +1,6 @@
{% if plugin_panels %}
<!-- Custom sidebar menu items, loaded via plugins -->
{% for panel in plugin_panels %}
{% include "sidebar_item.html" with label=panel.key text=panel.title icon=panel.icon %}
{% endfor %}
{% endif %}