diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py
index c6efb687f1..b3d81d75a5 100644
--- a/InvenTree/InvenTree/fields.py
+++ b/InvenTree/InvenTree/fields.py
@@ -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):
diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py
index 36ad66de24..8814c571dd 100644
--- a/InvenTree/InvenTree/forms.py
+++ b/InvenTree/InvenTree/forms.py
@@ -58,7 +58,7 @@ class HelperForm(forms.ModelForm):
def is_valid(self):
- valid = super(HelperForm, self).is_valid()
+ valid = super().is_valid()
return valid
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 5d0aea35e3..3012cdc61f 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -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',
])
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index aefed5156b..2b31d7c3b5 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -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
diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 183e491580..6291b321a8 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -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:
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 80d648f53a..c8ac4509b8 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -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()
diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py
index 8c23002800..6d8279558c 100644
--- a/InvenTree/company/views.py
+++ b/InvenTree/company/views.py
@@ -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'
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 81a96ba37e..f8102908e9 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -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'
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index aa3ad4963a..bcedc95da8 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -397,7 +397,7 @@
-
+
{% endblock %}
diff --git a/InvenTree/part/templates/part/part_sidebar.html b/InvenTree/part/templates/part/part_sidebar.html
index e8763fb973..1da07aa0c6 100644
--- a/InvenTree/part/templates/part/part_sidebar.html
+++ b/InvenTree/part/templates/part/part_sidebar.html
@@ -57,4 +57,4 @@
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label="part-attachments" text=text icon="fa-paperclip" %}
{% trans "Notes" as text %}
-{% include "sidebar_item.html" with label="part-notes" text=text icon="fa-clipboard" %}
+{% include "sidebar_item.html" with label="part-notes" text=text icon="fa-clipboard" %}
\ No newline at end of file
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index e024fca35b..b86e0acecc 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -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), 35)
+ self.assertEqual(len(result), len(InvenTreeUserSetting.SETTINGS))
def test_global_settings(self):
result = inventree_extras.global_settings()
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index efaf83ae95..f1d587e206 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -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()
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index ebe3ebf553..b22efc9415 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -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': 'Hello world',
+ }
+
+ """
+
+ 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
diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py
deleted file mode 100644
index 538bd2358b..0000000000
--- a/InvenTree/plugin/loader.py
+++ /dev/null
@@ -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)
diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py
index 900289ae37..fdbe863e19 100644
--- a/InvenTree/plugin/mixins/__init__.py
+++ b/InvenTree/plugin/mixins/__init__.py
@@ -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',
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 1249d95aa3..45961d7a8b 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -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
diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
new file mode 100644
index 0000000000..73ca863576
--- /dev/null
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -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': 'Hello world!',
+ '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': 'This content only appears on the PartDetail page, you know!',
+ })
+
+ # 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
diff --git a/InvenTree/plugin/samples/integration/templates/panel_demo/childless.html b/InvenTree/plugin/samples/integration/templates/panel_demo/childless.html
new file mode 100644
index 0000000000..061dcc514d
--- /dev/null
+++ b/InvenTree/plugin/samples/integration/templates/panel_demo/childless.html
@@ -0,0 +1,11 @@
+Template Rendering
+
+
+ This panel has been rendered using a template file!
+
+
+This location has no sublocations!
+
+ - Location Name: {{ location.name }}
+ - Location Path: {{ location.pathstring }}
+
diff --git a/InvenTree/plugin/template.py b/InvenTree/plugin/template.py
new file mode 100644
index 0000000000..53ee7bb6db
--- /dev/null
+++ b/InvenTree/plugin/template.py
@@ -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"""
+
+ Template file {template_file} does not exist.
+
+ """
+
+ # Render with the provided context
+ html = tmp.render(context)
+
+ return html
diff --git a/InvenTree/plugin/views.py b/InvenTree/plugin/views.py
new file mode 100644
index 0000000000..1b45fefbb1
--- /dev/null
+++ b/InvenTree/plugin/views.py
@@ -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
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 01d2b67c73..168403d692 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -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
"""
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index 94a166c84a..a451fb6544 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -69,6 +69,12 @@
{{ plugin.human_name }} - {{plugin_key}}
{% define plugin.registered_mixins as mixin_list %}
+ {% if plugin.is_sample %}
+
+ {% endif %}
+
{% if mixin_list %}
{% for mixin in mixin_list %}
- {% endif %}
-
{% if plugin.website %}
{% endif %}
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index 483a8ca6ad..0d8272892a 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -83,6 +83,7 @@
{% block sidebar %}
{% endblock %}
+ {% include "plugin/panel_menu.html" %}
{% include "sidebar_toggle.html" with target='sidebar' %}
@@ -209,6 +210,9 @@
|