From b80ff5e460304e78511e718d92be60c1500fc19e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 21:03:30 +1000
Subject: [PATCH 01/21] Tweak display of plugin badges

---
 InvenTree/templates/InvenTree/settings/plugin.html | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

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 @@
             <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 %}

From 170cb544907ecc22d1a99ea3fe71ce684fa218fd Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 21:30:27 +1000
Subject: [PATCH 02/21] Sort urls.py

---
 InvenTree/InvenTree/urls.py | 20 ++++++++------------
 1 file changed, 8 insertions(+), 12 deletions(-)

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

From 28e16616e5d8b574e8f24714bcac29a6c5bb2c5c Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 21:32:11 +1000
Subject: [PATCH 03/21] Adds a PanelMixin plugin mixin class

Intended to allow rendering of custom panels on pages
---
 .../plugin/builtin/integration/mixins.py      | 44 +++++++++++++++++++
 InvenTree/plugin/mixins/__init__.py           |  4 +-
 .../integration/custom_panel_sample.py        | 22 ++++++++++
 3 files changed, 69 insertions(+), 1 deletion(-)
 create mode 100644 InvenTree/plugin/samples/integration/custom_panel_sample.py

diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index ebe3ebf553..dce48304ec 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -542,3 +542,47 @@ 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:
+
+    - page: The name of the page e.g. 'part-detail'
+    - instance: The model instance specific to the page
+    - request: The request object responsible for the page load
+
+    It must return a list of CustomPanel class instances (see below).
+
+    Note that as this is called dynamically (per request),
+    then the actual panels returned can vary depending on the particular request or page
+
+    """
+
+    class CustomPanel:
+        ...
+
+    class MixinMeta:
+        MIXIN_NAME = 'Panel'
+    
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('panel', True, __class__)
+    
+    def get_custom_panels(self, page, instance, request):
+        raise NotImplementedError(f"{__class__} is missing the 'get_custom_panels' method")
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/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
new file mode 100644
index 0000000000..c2bae15548
--- /dev/null
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -0,0 +1,22 @@
+"""
+Sample plugin which renders custom panels on certain pages
+"""
+
+from plugin import IntegrationPluginBase
+from plugin.mixins import PanelMixin
+
+
+class CustomPanelSample(PanelMixin, IntegrationPluginBase):
+    """
+    A sample plugin which renders some custom panels.
+    """
+
+    PLUGIN_NAME = "CustomPanelExample"
+    PLUGIN_SLUG = "panel"
+    PLUGIN_TITLE = "Custom Panel Example"
+
+    def get_custom_panels(self, page, instance, request):
+
+        print("get_custom_panels:")
+
+        return []
\ No newline at end of file

From 7b8a10173dcb3c3d651dd3372c2da37e39554c62 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 22:49:51 +1000
Subject: [PATCH 04/21] Adds a new "Panel" mixin which can render custom panels
 on given pages

- Adds item to sidebar menu
- Adds panel content
- Runs custom javascript when the page is loaded
---
 .../plugin/builtin/integration/mixins.py      | 70 ++++++++++++++++---
 InvenTree/plugin/registry.py                  |  5 +-
 .../integration/custom_panel_sample.py        | 52 +++++++++++++-
 3 files changed, 112 insertions(+), 15 deletions(-)

diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index dce48304ec..57c9fb3f0a 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -9,6 +9,8 @@ import requests
 from django.urls import include, re_path
 from django.db.utils import OperationalError, ProgrammingError
 
+import InvenTree.helpers
+
 from plugin.models import PluginConfig, PluginSetting
 from plugin.urls import PLUGIN_BASE
 from plugin.helpers import MixinImplementationError, MixinNotImplementedError
@@ -563,26 +565,72 @@ class PanelMixin:
 
     This method is provided with:
 
-    - page: The name of the page e.g. 'part-detail'
-    - instance: The model instance specific to the page
-    - request: The request object responsible for the page load
-
-    It must return a list of CustomPanel class instances (see below).
+    - 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:
 
-    class CustomPanel:
-        ...
+    - 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, page, instance, request):
+
+    def render_panels(self, view, request):
+
+        panels = []
+
+        for panel in self.get_custom_panels(view, request):
+
+            if 'content_template' in panel:
+                # TODO: Render the actual content
+                ...
+
+            if 'javascript_template' in panel:
+                # TODO: Render the actual content
+                ...
+            
+            # 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 key")
+                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
+
+    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")
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
index c2bae15548..5cca44f524 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -5,6 +5,9 @@ Sample plugin which renders custom panels on certain pages
 from plugin import IntegrationPluginBase
 from plugin.mixins import PanelMixin
 
+from part.views import PartDetail
+from stock.views import StockLocationDetail
+
 
 class CustomPanelSample(PanelMixin, IntegrationPluginBase):
     """
@@ -15,8 +18,51 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
     PLUGIN_SLUG = "panel"
     PLUGIN_TITLE = "Custom Panel Example"
 
-    def get_custom_panels(self, page, instance, request):
+    def get_custom_panels(self, view, request):
 
-        print("get_custom_panels:")
+        panels = [
+            {
+                # 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': 'alert("Hello world");',
+            },
+            {
+                # This panel will not be displayed, as it is missing the 'content' key
+                'title': 'No Content',
+            }
+        ]
 
-        return []
\ No newline at end of file
+        # 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):
+            
+            print("yep, stocklocation view!")
+
+            try:
+                loc = view.get_object()
+
+                if not loc.get_descendants(include_self=False).exists():
+                    panels.append({
+                        'title': 'Childless',
+                        'icon': 'fa-user',
+                        'content': '<h4>I have no children!</h4>'
+                    })
+                else:
+                    print("abcdefgh")
+
+            except:
+                print("error could not get object!")
+                pass
+
+        return panels

From c80b36fc2fa30bfe6e981b078142a90621f21675 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 22:52:52 +1000
Subject: [PATCH 05/21] Adds a new InvenTreePluginMixin mixin class for
 enabling custom plugin rendering on a page

- Any view which needs custom plugin code must implement this mixin
- Initially implement for the PartDetail page
---
 InvenTree/InvenTree/views.py                  | 34 +++++++++++++++++++
 InvenTree/part/templates/part/detail.html     |  6 +++-
 .../part/templates/part/part_sidebar.html     |  2 ++
 InvenTree/part/views.py                       |  4 +--
 .../integration/custom_panel_sample.py        |  7 ----
 .../templates/panel/plugin_javascript.html    | 10 ++++++
 .../templates/panel/plugin_menu_items.html    |  3 ++
 InvenTree/templates/panel/plugin_panels.html  | 18 ++++++++++
 8 files changed, 74 insertions(+), 10 deletions(-)
 create mode 100644 InvenTree/templates/panel/plugin_javascript.html
 create mode 100644 InvenTree/templates/panel/plugin_menu_items.html
 create mode 100644 InvenTree/templates/panel/plugin_panels.html

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 183e491580..629ee1f31a 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -38,6 +38,8 @@ from part.models import PartCategory
 from common.models import InvenTreeSetting, ColorTheme
 from users.models import check_user_role, RuleSet
 
+from plugin.registry import registry
+
 from .forms import DeleteForm, EditUserForm, SetPasswordForm
 from .forms import SettingCategorySelectForm
 from .helpers import str2bool
@@ -56,6 +58,38 @@ def auth_request(request):
         return HttpResponse(status=403)
 
 
+class InvenTreePluginMixin:
+    """
+    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):
+        """
+        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)
+
+        return panels
+
+    def get_context_data(self, **kwargs):
+
+        ctx = super().get_context_data(**kwargs)
+
+        if settings.PLUGINS_ENABLED:
+            ctx['plugin_panels'] = self.get_plugin_panels()
+
+        return ctx
+
+
 class InvenTreeRoleMixin(PermissionRequiredMixin):
     """
     Permission class based on user roles, not user 'permissions'.
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index aa3ad4963a..cd8404be5b 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -397,9 +397,11 @@
             </div>
             <table class='table table-condensed table-striped' id='manufacturer-part-table' data-toolbar='#manufacturer-button-toolbar'></table>
         </div>
-    </div>
+    </div>    
 </div>
 
+{% include "panel/plugin_panels.html" %}
+
 {% endblock %}
 
 {% block js_load %}
@@ -1083,4 +1085,6 @@
         }
     });
 
+    {% include "panel/plugin_javascript.html" %}
+
 {% endblock %}
diff --git a/InvenTree/part/templates/part/part_sidebar.html b/InvenTree/part/templates/part/part_sidebar.html
index e8763fb973..18890b82af 100644
--- a/InvenTree/part/templates/part/part_sidebar.html
+++ b/InvenTree/part/templates/part/part_sidebar.html
@@ -58,3 +58,5 @@
 {% 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 "panel/plugin_menu_items.html" %}
\ No newline at end of file
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index efaf83ae95..cb9725f94e 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -49,7 +49,7 @@ from order.models import PurchaseOrderLineItem
 
 from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
 from InvenTree.views import QRCodeView
-from InvenTree.views import InvenTreeRoleMixin
+from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
 
 from InvenTree.helpers import str2bool
 
@@ -365,7 +365,7 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
         return PartImport.validate(self, self.steps.current, form, **kwargs)
 
 
-class PartDetail(InvenTreeRoleMixin, DetailView):
+class PartDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
     """ Detail view for Part object
     """
 
diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index 5cca44f524..3b999cce27 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -46,9 +46,6 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
         # This panel will *only* display on the StockLocation view,
         # and *only* if the StockLocation has *no* child locations
         if isinstance(view, StockLocationDetail):
-            
-            print("yep, stocklocation view!")
-
             try:
                 loc = view.get_object()
 
@@ -58,11 +55,7 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
                         'icon': 'fa-user',
                         'content': '<h4>I have no children!</h4>'
                     })
-                else:
-                    print("abcdefgh")
-
             except:
-                print("error could not get object!")
                 pass
 
         return panels
diff --git a/InvenTree/templates/panel/plugin_javascript.html b/InvenTree/templates/panel/plugin_javascript.html
new file mode 100644
index 0000000000..bf8b7fea34
--- /dev/null
+++ b/InvenTree/templates/panel/plugin_javascript.html
@@ -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 %}
\ No newline at end of file
diff --git a/InvenTree/templates/panel/plugin_menu_items.html b/InvenTree/templates/panel/plugin_menu_items.html
new file mode 100644
index 0000000000..2c084a021e
--- /dev/null
+++ b/InvenTree/templates/panel/plugin_menu_items.html
@@ -0,0 +1,3 @@
+{% for panel in plugin_panels %}
+{% include "sidebar_item.html" with label=panel.key text=panel.title icon=panel.icon %}
+{% endfor %}
\ No newline at end of file
diff --git a/InvenTree/templates/panel/plugin_panels.html b/InvenTree/templates/panel/plugin_panels.html
new file mode 100644
index 0000000000..ddbdbeee45
--- /dev/null
+++ b/InvenTree/templates/panel/plugin_panels.html
@@ -0,0 +1,18 @@
+{% 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 %}
\ No newline at end of file

From 71128a1c8e643c046f75a8dc88e72113603ed0c1 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 22:57:15 +1000
Subject: [PATCH 06/21] Refactor the plugin javascript template

- Can appear in "base.html"
- Only renders anything if there are actually plugins available for the page
---
 InvenTree/part/templates/part/detail.html | 2 --
 InvenTree/templates/base.html             | 3 +++
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index cd8404be5b..e2e3d27be0 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -1085,6 +1085,4 @@
         }
     });
 
-    {% include "panel/plugin_javascript.html" %}
-
 {% endblock %}
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index 483a8ca6ad..7dbb17c7d8 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -209,6 +209,9 @@
 <script type='text/javascript'>
 
 $(document).ready(function () {
+
+    {% include "panel/plugin_javascript.html" %}
+
 {% block js_ready %}
 {% endblock %}
 

From 0797e9ebf02986d214ae897d9ed4b896c6c26f89 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 23:19:21 +1000
Subject: [PATCH 07/21] Simplify the new template rendering

- No extra template code is required for any new page
- All loaded in base.html or page_base.html
- Oh, so clean!
---
 InvenTree/part/templates/part/detail.html                  | 2 --
 InvenTree/part/templates/part/part_sidebar.html            | 4 +---
 InvenTree/templates/base.html                              | 3 ++-
 InvenTree/templates/page_base.html                         | 2 ++
 .../plugin_panels.html => plugin/panel_content.html}       | 7 ++++---
 .../{panel/plugin_javascript.html => plugin/panel_js.html} | 0
 .../plugin_menu_items.html => plugin/panel_menu.html}      | 5 ++++-
 7 files changed, 13 insertions(+), 10 deletions(-)
 rename InvenTree/templates/{panel/plugin_panels.html => plugin/panel_content.html} (84%)
 rename InvenTree/templates/{panel/plugin_javascript.html => plugin/panel_js.html} (100%)
 rename InvenTree/templates/{panel/plugin_menu_items.html => plugin/panel_menu.html} (54%)

diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index e2e3d27be0..bcedc95da8 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -400,8 +400,6 @@
     </div>    
 </div>
 
-{% include "panel/plugin_panels.html" %}
-
 {% endblock %}
 
 {% block js_load %}
diff --git a/InvenTree/part/templates/part/part_sidebar.html b/InvenTree/part/templates/part/part_sidebar.html
index 18890b82af..1da07aa0c6 100644
--- a/InvenTree/part/templates/part/part_sidebar.html
+++ b/InvenTree/part/templates/part/part_sidebar.html
@@ -57,6 +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 "panel/plugin_menu_items.html" %}
\ No newline at end of file
+{% include "sidebar_item.html" with label="part-notes" text=text icon="fa-clipboard" %}
\ No newline at end of file
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index 7dbb17c7d8..0d8272892a 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -83,6 +83,7 @@
                         {% block sidebar %}
                         <!-- Sidebar goes here -->
                         {% endblock %}
+                        {% include "plugin/panel_menu.html" %}
                         {% include "sidebar_toggle.html" with target='sidebar' %}
                     </ul>
                 </div>
@@ -210,7 +211,7 @@
 
 $(document).ready(function () {
 
-    {% include "panel/plugin_javascript.html" %}
+    {% include "plugin/panel_js.html" %}
 
 {% block js_ready %}
 {% endblock %}
diff --git a/InvenTree/templates/page_base.html b/InvenTree/templates/page_base.html
index 17077700a2..9dcd33912d 100644
--- a/InvenTree/templates/page_base.html
+++ b/InvenTree/templates/page_base.html
@@ -61,6 +61,8 @@
 </div>
 
 {% block page_content %}
+<!-- Custom page content goes here-->
 {% endblock %}
+{% include "plugin/panel_content.html" %}
 
 {% endblock %}
diff --git a/InvenTree/templates/panel/plugin_panels.html b/InvenTree/templates/plugin/panel_content.html
similarity index 84%
rename from InvenTree/templates/panel/plugin_panels.html
rename to InvenTree/templates/plugin/panel_content.html
index ddbdbeee45..6b5c4ed90b 100644
--- a/InvenTree/templates/panel/plugin_panels.html
+++ b/InvenTree/templates/plugin/panel_content.html
@@ -1,5 +1,6 @@
+{% 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'>
@@ -14,5 +15,5 @@
         {{ panel.content | safe }}
     </div>
 </div>
-
-{% endfor %}
\ No newline at end of file
+{% endfor %}
+{% endif %}
\ No newline at end of file
diff --git a/InvenTree/templates/panel/plugin_javascript.html b/InvenTree/templates/plugin/panel_js.html
similarity index 100%
rename from InvenTree/templates/panel/plugin_javascript.html
rename to InvenTree/templates/plugin/panel_js.html
diff --git a/InvenTree/templates/panel/plugin_menu_items.html b/InvenTree/templates/plugin/panel_menu.html
similarity index 54%
rename from InvenTree/templates/panel/plugin_menu_items.html
rename to InvenTree/templates/plugin/panel_menu.html
index 2c084a021e..bbbadfdc64 100644
--- a/InvenTree/templates/panel/plugin_menu_items.html
+++ b/InvenTree/templates/plugin/panel_menu.html
@@ -1,3 +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 %}
\ No newline at end of file
+{% endfor %}
+{% endif %}
\ No newline at end of file

From 12c58b14d6d40fefdfccf688e84fb690962dbec7 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 23:19:47 +1000
Subject: [PATCH 08/21] Improvements for panel mixin sample

---
 InvenTree/InvenTree/views.py                   |  1 -
 InvenTree/plugin/builtin/integration/mixins.py |  6 +++---
 .../samples/integration/custom_panel_sample.py | 18 ++++++++++++++++--
 3 files changed, 19 insertions(+), 6 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 629ee1f31a..d9a9495bbd 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -75,7 +75,6 @@ class InvenTreePluginMixin:
         panels = []
 
         for plug in registry.with_mixin('panel'):
-
             panels += plug.render_panels(self, self.request)
 
         return panels
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index 57c9fb3f0a..fbe7e383a9 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -606,18 +606,18 @@ class PanelMixin:
         for panel in self.get_custom_panels(view, request):
 
             if 'content_template' in panel:
-                # TODO: Render the actual content
+                # TODO: Render the actual HTML content from a template file
                 ...
 
             if 'javascript_template' in panel:
-                # TODO: Render the actual content
+                # TODO: Render the actual javascript content from a template file
                 ...
             
             # 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 key")
+                logger.warning(f"Custom panel for plugin {__class__} is missing a required parameter")
                 continue
 
             # Add some information on this plugin
diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index 3b999cce27..be6567a5fb 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -18,6 +18,19 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
     PLUGIN_SLUG = "panel"
     PLUGIN_TITLE = "Custom Panel Example"
 
+    def render_location_info(self, loc):
+        """
+        Demonstrate that we can render information particular to a page
+        """
+        return f"""
+        <h5>Location Information</h5>
+        <em>This location has no sublocations!</em>
+        <ul>
+        <li><b>Name</b>: {loc.name}</li>
+        <li><b>Path</b>: {loc.pathstring}</li>
+        </ul>
+        """
+
     def get_custom_panels(self, view, request):
 
         panels = [
@@ -46,14 +59,15 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
         # 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',
+                        'title': 'Childless Location',
                         'icon': 'fa-user',
-                        'content': '<h4>I have no children!</h4>'
+                        'content': self.render_location_info(loc),
                     })
             except:
                 pass

From 243e3ff37d62c50bd839df1bf8eb2211de2cef12 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 23:32:09 +1000
Subject: [PATCH 09/21] Fix calls to super()

---
 InvenTree/InvenTree/fields.py      | 4 ++--
 InvenTree/InvenTree/forms.py       | 2 +-
 InvenTree/InvenTree/serializers.py | 2 +-
 InvenTree/InvenTree/views.py       | 4 ++--
 InvenTree/build/views.py           | 4 ++--
 InvenTree/part/views.py            | 6 +++---
 6 files changed, 11 insertions(+), 11 deletions(-)

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/serializers.py b/InvenTree/InvenTree/serializers.py
index 3e57883875..88132ad606 100644
--- a/InvenTree/InvenTree/serializers.py
+++ b/InvenTree/InvenTree/serializers.py
@@ -51,7 +51,7 @@ class InvenTreeMoneySerializer(MoneyField):
         Test that the returned amount is a valid Decimal
         """
 
-        amount = super(DecimalField, self).get_value(data)
+        amount = super().get_value(data)
 
         # Convert an empty string to None
         if len(str(amount).strip()) == 0:
diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index d9a9495bbd..23ca44cc83 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -687,7 +687,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
 
@@ -882,7 +882,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..d594a1ce1a 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -29,7 +29,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
 
     def get_context_data(self, **kwargs):
 
-        context = super(BuildIndex, self).get_context_data(**kwargs).copy()
+        context = super(self).get_context_data(**kwargs).copy()
 
         context['BuildStatus'] = BuildStatus
 
@@ -52,7 +52,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/part/views.py b/InvenTree/part/views.py
index cb9725f94e..c43d604808 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -979,7 +979,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 +1045,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 +1140,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()

From 60f799c90a486ed763ff6899a5e679476a098013 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 23:32:42 +1000
Subject: [PATCH 10/21] Add plugin view support for most of the remaining views

---
 InvenTree/build/views.py   |  4 ++--
 InvenTree/company/views.py |  8 ++++----
 InvenTree/order/views.py   |  6 +++---
 InvenTree/part/views.py    |  4 ++--
 InvenTree/stock/views.py   | 10 +++++-----
 5 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index d594a1ce1a..ed240763f7 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -11,7 +11,7 @@ from django.views.generic import DetailView, ListView
 from .models import Build
 
 from InvenTree.views import AjaxDeleteView
-from InvenTree.views import InvenTreeRoleMixin
+from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
 from InvenTree.status_codes import BuildStatus
 
 
@@ -41,7 +41,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
         return context
 
 
-class BuildDetail(InvenTreeRoleMixin, DetailView):
+class BuildDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
     """
     Detail view of a single Build object.
     """
diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py
index 8c23002800..4dff4377b9 100644
--- a/InvenTree/company/views.py
+++ b/InvenTree/company/views.py
@@ -17,7 +17,7 @@ import requests
 import io
 
 from InvenTree.views import AjaxUpdateView
-from InvenTree.views import InvenTreeRoleMixin
+from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
 
 from .models import Company
 from .models import ManufacturerPart
@@ -104,7 +104,7 @@ class CompanyIndex(InvenTreeRoleMixin, ListView):
         return queryset
 
 
-class CompanyDetail(DetailView):
+class CompanyDetail(InvenTreePluginMixin, DetailView):
     """ Detail view for Company object """
     context_obect_name = 'company'
     template_name = 'company/detail.html'
@@ -196,7 +196,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
         )
 
 
-class ManufacturerPartDetail(DetailView):
+class ManufacturerPartDetail(InvenTreePluginMixin, DetailView):
     """ Detail view for ManufacturerPart """
     model = ManufacturerPart
     template_name = 'company/manufacturer_part_detail.html'
@@ -210,7 +210,7 @@ class ManufacturerPartDetail(DetailView):
         return ctx
 
 
-class SupplierPartDetail(DetailView):
+class SupplierPartDetail(InvenTreePluginMixin, 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..532b7b244e 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -31,7 +31,7 @@ from . import forms as order_forms
 from part.views import PartPricing
 
 from InvenTree.helpers import DownloadFile
-from InvenTree.views import InvenTreeRoleMixin, AjaxView
+from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin, AjaxView
 
 
 logger = logging.getLogger("inventree")
@@ -65,7 +65,7 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
     context_object_name = 'orders'
 
 
-class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
+class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
     """ Detail view for a PurchaseOrder object """
 
     context_object_name = 'order'
@@ -78,7 +78,7 @@ class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
         return ctx
 
 
-class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
+class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
     """ Detail view for a SalesOrder object """
 
     context_object_name = 'order'
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index c43d604808..1d3c3b8f19 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -67,7 +67,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)
@@ -969,7 +969,7 @@ class PartParameterTemplateDelete(AjaxDeleteView):
     ajax_form_title = _("Delete Part Parameter Template")
 
 
-class CategoryDetail(InvenTreeRoleMixin, DetailView):
+class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
     """ Detail view for PartCategory """
 
     model = PartCategory
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 01d2b67c73..03429d4b8c 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -15,7 +15,7 @@ from django.utils.translation import gettext_lazy as _
 
 from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
 from InvenTree.views import QRCodeView
-from InvenTree.views import InvenTreeRoleMixin
+from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
 from InvenTree.forms import ConfirmForm
 
 from InvenTree.helpers import str2bool
@@ -27,7 +27,7 @@ import common.settings
 from . import forms as StockForms
 
 
-class StockIndex(InvenTreeRoleMixin, ListView):
+class StockIndex(InvenTreeRoleMixin, InvenTreePluginMixin, ListView):
     """ StockIndex view loads all StockLocation and StockItem object
     """
     model = StockItem
@@ -35,7 +35,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 +54,7 @@ class StockIndex(InvenTreeRoleMixin, ListView):
         return context
 
 
-class StockLocationDetail(InvenTreeRoleMixin, DetailView):
+class StockLocationDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
     """
     Detailed view of a single StockLocation object
     """
@@ -75,7 +75,7 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView):
         return context
 
 
-class StockItemDetail(InvenTreeRoleMixin, DetailView):
+class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
     """
     Detailed view of a single StockItem object
     """

From 5ed0128435b67dc260e9091ef4a65d74014bf855 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 23:34:34 +1000
Subject: [PATCH 11/21] PEP style fixes

---
 InvenTree/InvenTree/serializers.py             | 1 -
 InvenTree/plugin/builtin/integration/mixins.py | 6 +++---
 2 files changed, 3 insertions(+), 4 deletions(-)

diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py
index 88132ad606..243594c77e 100644
--- a/InvenTree/InvenTree/serializers.py
+++ b/InvenTree/InvenTree/serializers.py
@@ -26,7 +26,6 @@ from rest_framework import serializers
 from rest_framework.utils import model_meta
 from rest_framework.fields import empty
 from rest_framework.exceptions import ValidationError
-from rest_framework.serializers import DecimalField
 
 from .models import extract_int
 
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index fbe7e383a9..b5040de78f 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -612,7 +612,7 @@ class PanelMixin:
             if 'javascript_template' in panel:
                 # TODO: Render the actual javascript content from a template file
                 ...
-            
+
             # Check for required keys
             required_keys = ['title', 'content']
 
@@ -623,9 +623,9 @@ class PanelMixin:
             # 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')) 
+            panel['key'] = InvenTree.helpers.generateTestKey(self.slug + panel.get('title', 'panel'))
 
             panels.append(panel)
 

From bcf6e41b489b5447186c063193d32714856bdfc7 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 23:43:54 +1000
Subject: [PATCH 12/21] Add some example docs

---
 .../plugin/samples/integration/custom_panel_sample.py    | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index be6567a5fb..8b73f793d1 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -33,6 +33,15 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
 
     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 'hello world' panel will be displayed on any view which implements custom panels

From 96f61dfcdbf156b89b11ce55feb4333184758498 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 6 May 2022 23:48:24 +1000
Subject: [PATCH 13/21] Add plugin description

---
 InvenTree/plugin/samples/integration/custom_panel_sample.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index 8b73f793d1..e5b027fdac 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -17,6 +17,8 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
     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"
 
     def render_location_info(self, loc):
         """

From 44c4e8864690fc0b1da13f0356146a048f9c5ac3 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 7 May 2022 02:17:20 +1000
Subject: [PATCH 14/21] Add a configurable setting to the demo plugin

---
 .../integration/custom_panel_sample.py        | 27 +++++++++++++------
 1 file changed, 19 insertions(+), 8 deletions(-)

diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index e5b027fdac..0eada9c8ab 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -3,13 +3,13 @@ Sample plugin which renders custom panels on certain pages
 """
 
 from plugin import IntegrationPluginBase
-from plugin.mixins import PanelMixin
+from plugin.mixins import PanelMixin, SettingsMixin
 
 from part.views import PartDetail
 from stock.views import StockLocationDetail
 
 
-class CustomPanelSample(PanelMixin, IntegrationPluginBase):
+class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
     """
     A sample plugin which renders some custom panels.
     """
@@ -20,6 +20,15 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
     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 render_location_info(self, loc):
         """
         Demonstrate that we can render information particular to a page
@@ -46,18 +55,20 @@ class CustomPanelSample(PanelMixin, IntegrationPluginBase):
 
         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': 'alert("Hello world");',
-            },
-            {
-                # This panel will not be displayed, as it is missing the 'content' key
-                'title': 'No Content',
-            }
-        ]
+            })
 
         # This panel will *only* display on the PartDetail view
         if isinstance(view, PartDetail):

From 103921f5c47e00d7a4da8c0a77b9ca4084789355 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 7 May 2022 19:59:59 +1000
Subject: [PATCH 15/21] Rename plugin.loader to plugin.template

- Add helper function for rendering a template
---
 InvenTree/InvenTree/settings.py |  2 +-
 InvenTree/plugin/loader.py      | 19 ----------
 InvenTree/plugin/template.py    | 66 +++++++++++++++++++++++++++++++++
 3 files changed, 67 insertions(+), 20 deletions(-)
 delete mode 100644 InvenTree/plugin/loader.py
 create mode 100644 InvenTree/plugin/template.py

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/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/template.py b/InvenTree/plugin/template.py
new file mode 100644
index 0000000000..7e1da81609
--- /dev/null
+++ b/InvenTree/plugin/template.py
@@ -0,0 +1,66 @@
+"""
+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)  # pragma: no cover
+
+        return tuple(template_dirs)
+
+
+def render_template(plugin, template_file, context=None):
+    """
+    Locate and render a template file, available in the global template context.
+    """
+
+    print("render_template", "->", template_file)
+    print("Context:")
+    print(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

From 9f15dd8e2a6a9a4592a05e707fb9a561ec9e0dfc Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 7 May 2022 22:32:26 +1000
Subject: [PATCH 16/21] Custom panels can now be rendered from a template

---
 InvenTree/InvenTree/views.py                  |  6 +--
 .../plugin/builtin/integration/mixins.py      | 54 ++++++++++++++-----
 .../integration/custom_panel_sample.py        | 23 ++++----
 .../templates/panel_demo/childless.html       | 11 ++++
 InvenTree/plugin/template.py                  |  4 --
 5 files changed, 65 insertions(+), 33 deletions(-)
 create mode 100644 InvenTree/plugin/samples/integration/templates/panel_demo/childless.html

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 23ca44cc83..f98a1b1cdc 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -67,7 +67,7 @@ class InvenTreePluginMixin:
 
     """
 
-    def get_plugin_panels(self):
+    def get_plugin_panels(self, ctx):
         """
         Return a list of extra 'plugin panels' associated with this view
         """
@@ -75,7 +75,7 @@ class InvenTreePluginMixin:
         panels = []
 
         for plug in registry.with_mixin('panel'):
-            panels += plug.render_panels(self, self.request)
+            panels += plug.render_panels(self, self.request, ctx)
 
         return panels
 
@@ -84,7 +84,7 @@ class InvenTreePluginMixin:
         ctx = super().get_context_data(**kwargs)
 
         if settings.PLUGINS_ENABLED:
-            ctx['plugin_panels'] = self.get_plugin_panels()
+            ctx['plugin_panels'] = self.get_plugin_panels(ctx)
 
         return ctx
 
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index b5040de78f..d50a9ce729 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -11,9 +11,10 @@ from django.db.utils import OperationalError, ProgrammingError
 
 import InvenTree.helpers
 
-from plugin.models import PluginConfig, PluginSetting
-from plugin.urls import PLUGIN_BASE
 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')
@@ -599,19 +600,50 @@ class PanelMixin:
         super().__init__()
         self.add_mixin('panel', True, __class__)
 
-    def render_panels(self, view, request):
+    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):
 
-            if 'content_template' in panel:
-                # TODO: Render the actual HTML content from a template file
-                ...
+            content_template = panel.get('content_template', None)
+            javascript_template = panel.get('javascript_template', None)
 
-            if 'javascript_template' in panel:
-                # TODO: Render the actual javascript content from a template file
-                ...
+            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']
@@ -630,7 +662,3 @@ class PanelMixin:
             panels.append(panel)
 
         return panels
-
-    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")
diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index 0eada9c8ab..e0b84fe01a 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -29,18 +29,15 @@ class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
         }
     }
 
-    def render_location_info(self, loc):
-        """
-        Demonstrate that we can render information particular to a page
-        """
-        return f"""
-        <h5>Location Information</h5>
-        <em>This location has no sublocations!</em>
-        <ul>
-        <li><b>Name</b>: {loc.name}</li>
-        <li><b>Path</b>: {loc.pathstring}</li>
-        </ul>
-        """
+    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):
 
@@ -89,7 +86,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
                     panels.append({
                         'title': 'Childless Location',
                         'icon': 'fa-user',
-                        'content': self.render_location_info(loc),
+                        'content_template': 'panel_demo/childless.html',  # Note that the panel content is rendered using a template file!
                     })
             except:
                 pass
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 @@
+<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>
diff --git a/InvenTree/plugin/template.py b/InvenTree/plugin/template.py
index 7e1da81609..0f580a8023 100644
--- a/InvenTree/plugin/template.py
+++ b/InvenTree/plugin/template.py
@@ -45,10 +45,6 @@ def render_template(plugin, template_file, context=None):
     Locate and render a template file, available in the global template context.
     """
 
-    print("render_template", "->", template_file)
-    print("Context:")
-    print(context)
-
     try:
         tmp = template.loader.get_template(template_file)
     except template.TemplateDoesNotExist:

From 06e79ee91bc9927bb79a3bf632a10ccaffe01b3c Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 7 May 2022 22:33:30 +1000
Subject: [PATCH 17/21] Move view mixin to plugin.views

---
 InvenTree/InvenTree/views.py | 33 -----------------------------
 InvenTree/build/views.py     |  6 ++++--
 InvenTree/company/views.py   | 11 +++++-----
 InvenTree/order/views.py     |  8 +++++---
 InvenTree/part/views.py      |  8 +++++---
 InvenTree/plugin/views.py    | 40 ++++++++++++++++++++++++++++++++++++
 InvenTree/stock/views.py     | 10 +++++----
 7 files changed, 66 insertions(+), 50 deletions(-)
 create mode 100644 InvenTree/plugin/views.py

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index f98a1b1cdc..6291b321a8 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -38,8 +38,6 @@ from part.models import PartCategory
 from common.models import InvenTreeSetting, ColorTheme
 from users.models import check_user_role, RuleSet
 
-from plugin.registry import registry
-
 from .forms import DeleteForm, EditUserForm, SetPasswordForm
 from .forms import SettingCategorySelectForm
 from .helpers import str2bool
@@ -58,37 +56,6 @@ def auth_request(request):
         return HttpResponse(status=403)
 
 
-class InvenTreePluginMixin:
-    """
-    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):
-
-        ctx = super().get_context_data(**kwargs)
-
-        if settings.PLUGINS_ENABLED:
-            ctx['plugin_panels'] = self.get_plugin_panels(ctx)
-
-        return ctx
-
-
 class InvenTreeRoleMixin(PermissionRequiredMixin):
     """
     Permission class based on user roles, not user 'permissions'.
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index ed240763f7..6b44ae3dfc 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -11,9 +11,11 @@ from django.views.generic import DetailView, ListView
 from .models import Build
 
 from InvenTree.views import AjaxDeleteView
-from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
+from InvenTree.views import InvenTreeRoleMixin
 from InvenTree.status_codes import BuildStatus
 
+from plugin.views import InvenTreePluginViewMixin
+
 
 class BuildIndex(InvenTreeRoleMixin, ListView):
     """
@@ -41,7 +43,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
         return context
 
 
-class BuildDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
+class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
     """
     Detail view of a single Build object.
     """
diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py
index 4dff4377b9..6d8279558c 100644
--- a/InvenTree/company/views.py
+++ b/InvenTree/company/views.py
@@ -17,15 +17,16 @@ import requests
 import io
 
 from InvenTree.views import AjaxUpdateView
-from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
+from InvenTree.views import InvenTreeRoleMixin
 
 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(InvenTreePluginMixin, 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(InvenTreePluginMixin, DetailView):
+class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView):
     """ Detail view for ManufacturerPart """
     model = ManufacturerPart
     template_name = 'company/manufacturer_part_detail.html'
@@ -210,7 +211,7 @@ class ManufacturerPartDetail(InvenTreePluginMixin, DetailView):
         return ctx
 
 
-class SupplierPartDetail(InvenTreePluginMixin, 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 532b7b244e..f8102908e9 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -31,7 +31,9 @@ from . import forms as order_forms
 from part.views import PartPricing
 
 from InvenTree.helpers import DownloadFile
-from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin, AjaxView
+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, InvenTreePluginMixin, DetailView):
+class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
     """ Detail view for a PurchaseOrder object """
 
     context_object_name = 'order'
@@ -78,7 +80,7 @@ class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
         return ctx
 
 
-class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
+class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
     """ Detail view for a SalesOrder object """
 
     context_object_name = 'order'
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 1d3c3b8f19..f1d587e206 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -49,10 +49,12 @@ from order.models import PurchaseOrderLineItem
 
 from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
 from InvenTree.views import QRCodeView
-from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
+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
@@ -365,7 +367,7 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
         return PartImport.validate(self, self.steps.current, form, **kwargs)
 
 
-class PartDetail(InvenTreeRoleMixin, InvenTreePluginMixin, 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, InvenTreePluginMixin, DetailView):
+class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
     """ Detail view for PartCategory """
 
     model = PartCategory
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 03429d4b8c..168403d692 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -15,7 +15,7 @@ from django.utils.translation import gettext_lazy as _
 
 from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
 from InvenTree.views import QRCodeView
-from InvenTree.views import InvenTreeRoleMixin, InvenTreePluginMixin
+from InvenTree.views import InvenTreeRoleMixin
 from InvenTree.forms import ConfirmForm
 
 from InvenTree.helpers import str2bool
@@ -26,8 +26,10 @@ import common.settings
 
 from . import forms as StockForms
 
+from plugin.views import InvenTreePluginViewMixin
 
-class StockIndex(InvenTreeRoleMixin, InvenTreePluginMixin, ListView):
+
+class StockIndex(InvenTreeRoleMixin, InvenTreePluginViewMixin, ListView):
     """ StockIndex view loads all StockLocation and StockItem object
     """
     model = StockItem
@@ -54,7 +56,7 @@ class StockIndex(InvenTreeRoleMixin, InvenTreePluginMixin, ListView):
         return context
 
 
-class StockLocationDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
+class StockLocationDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
     """
     Detailed view of a single StockLocation object
     """
@@ -75,7 +77,7 @@ class StockLocationDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
         return context
 
 
-class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginMixin, DetailView):
+class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
     """
     Detailed view of a single StockItem object
     """

From e57a3870c6af815ba786c855fbd5ec7dda7eecb4 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 7 May 2022 22:47:48 +1000
Subject: [PATCH 18/21] Fix build index template

---
 InvenTree/build/views.py                                    | 2 +-
 InvenTree/plugin/builtin/integration/mixins.py              | 2 +-
 InvenTree/plugin/samples/integration/custom_panel_sample.py | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 6b44ae3dfc..c8ac4509b8 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -31,7 +31,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
 
     def get_context_data(self, **kwargs):
 
-        context = super(self).get_context_data(**kwargs).copy()
+        context = super().get_context_data(**kwargs)
 
         context['BuildStatus'] = BuildStatus
 
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index d50a9ce729..b22efc9415 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -622,7 +622,7 @@ class PanelMixin:
             context['object'] = view.get_object()
         except AttributeError:
             pass
-            
+
         return context
 
     def render_panels(self, view, request, context):
diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py
index e0b84fe01a..73ca863576 100644
--- a/InvenTree/plugin/samples/integration/custom_panel_sample.py
+++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py
@@ -64,7 +64,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
                 'icon': 'fas fa-boxes',
                 'content': '<b>Hello world!</b>',
                 'description': 'A simple panel which renders hello world',
-                'javascript': 'alert("Hello world");',
+                'javascript': 'console.log("Hello world, from a custom panel!");',
             })
 
         # This panel will *only* display on the PartDetail view

From b2689b943ebe9d246f821c7c6796c9ab1f513733 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 7 May 2022 23:22:54 +1000
Subject: [PATCH 19/21] Specific call to super() was actually needed

---
 InvenTree/InvenTree/serializers.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py
index 243594c77e..3e57883875 100644
--- a/InvenTree/InvenTree/serializers.py
+++ b/InvenTree/InvenTree/serializers.py
@@ -26,6 +26,7 @@ from rest_framework import serializers
 from rest_framework.utils import model_meta
 from rest_framework.fields import empty
 from rest_framework.exceptions import ValidationError
+from rest_framework.serializers import DecimalField
 
 from .models import extract_int
 
@@ -50,7 +51,7 @@ class InvenTreeMoneySerializer(MoneyField):
         Test that the returned amount is a valid Decimal
         """
 
-        amount = super().get_value(data)
+        amount = super(DecimalField, self).get_value(data)
 
         # Convert an empty string to None
         if len(str(amount).strip()) == 0:

From 50d8f242bb35ffdd74bd62c45a6b4a9d816ed098 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 7 May 2022 23:50:41 +1000
Subject: [PATCH 20/21] Fix for unit test

---
 InvenTree/part/test_part.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index f3e1d4490d..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), 36)
+        self.assertEqual(len(result), len(InvenTreeUserSetting.SETTINGS))
 
     def test_global_settings(self):
         result = inventree_extras.global_settings()

From ada1eeeb3554ffc3ce0f6af92c12a10eda3e34b3 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 8 May 2022 07:53:21 +1000
Subject: [PATCH 21/21] Remove 'no cover'

---
 InvenTree/plugin/template.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/plugin/template.py b/InvenTree/plugin/template.py
index 0f580a8023..53ee7bb6db 100644
--- a/InvenTree/plugin/template.py
+++ b/InvenTree/plugin/template.py
@@ -35,7 +35,7 @@ class PluginTemplateLoader(FilesystemLoader):
         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
+                template_dirs.append(new_path)
 
         return tuple(template_dirs)