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 @@
             </div>
             <table class='table table-condensed table-striped' id='manufacturer-part-table' data-toolbar='#manufacturer-button-toolbar'></table>
         </div>
-    </div>
+    </div>    
 </div>
 
 {% 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 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()
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': '<b>Hello world</b>',
+    }
+
+    """
+
+    class MixinMeta:
+        MIXIN_NAME = 'Panel'
+
+    def __init__(self):
+        super().__init__()
+        self.add_mixin('panel', True, __class__)
+
+    def get_custom_panels(self, view, request):
+        """ This method *must* be implemented by the plugin class """
+        raise NotImplementedError(f"{__class__} is missing the 'get_custom_panels' method")
+
+    def get_panel_context(self, view, request, context):
+        """
+        Build the context data to be used for template rendering.
+        Custom class can override this to provide any custom context data.
+
+        (See the example in "custom_panel_sample.py")
+        """
+
+        # Provide some standard context items to the template for rendering
+        context['plugin'] = self
+        context['request'] = request
+        context['user'] = getattr(request, 'user', None)
+        context['view'] = view
+
+        try:
+            context['object'] = view.get_object()
+        except AttributeError:
+            pass
+
+        return context
+
+    def render_panels(self, view, request, context):
+
+        panels = []
+
+        # Construct an updated context object for template rendering
+        ctx = self.get_panel_context(view, request, context)
+
+        for panel in self.get_custom_panels(view, request):
+
+            content_template = panel.get('content_template', None)
+            javascript_template = panel.get('javascript_template', None)
+
+            if content_template:
+                # Render content template to HTML
+                panel['content'] = render_template(self, content_template, ctx)
+
+            if javascript_template:
+                # Render javascript template to HTML
+                panel['javascript'] = render_template(self, javascript_template, ctx)
+
+            # Check for required keys
+            required_keys = ['title', 'content']
+
+            if any([key not in panel for key in required_keys]):
+                logger.warning(f"Custom panel for plugin {__class__} is missing a required parameter")
+                continue
+
+            # Add some information on this plugin
+            panel['plugin'] = self
+            panel['slug'] = self.slug
+
+            # Add a 'key' for the panel, which is mostly guaranteed to be unique
+            panel['key'] = InvenTree.helpers.generateTestKey(self.slug + panel.get('title', 'panel'))
+
+            panels.append(panel)
+
+        return panels
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': '<b>Hello world!</b>',
+                'description': 'A simple panel which renders hello world',
+                'javascript': 'console.log("Hello world, from a custom panel!");',
+            })
+
+        # This panel will *only* display on the PartDetail view
+        if isinstance(view, PartDetail):
+            panels.append({
+                'title': 'Custom Part Panel',
+                'icon': 'fas fa-shapes',
+                'content': '<em>This content only appears on the PartDetail page, you know!</em>',
+            })
+
+        # This panel will *only* display on the StockLocation view,
+        # and *only* if the StockLocation has *no* child locations
+        if isinstance(view, StockLocationDetail):
+
+            try:
+                loc = view.get_object()
+
+                if not loc.get_descendants(include_self=False).exists():
+                    panels.append({
+                        'title': 'Childless Location',
+                        'icon': 'fa-user',
+                        'content_template': 'panel_demo/childless.html',  # Note that the panel content is rendered using a template file!
+                    })
+            except:
+                pass
+
+        return panels
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
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"""
+        <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
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 @@
             <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 %}
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 %}
                         <!-- Sidebar goes here -->
                         {% endblock %}
+                        {% include "plugin/panel_menu.html" %}
                         {% include "sidebar_toggle.html" with target='sidebar' %}
                     </ul>
                 </div>
@@ -209,6 +210,9 @@
 <script type='text/javascript'>
 
 $(document).ready(function () {
+
+    {% include "plugin/panel_js.html" %}
+
 {% block js_ready %}
 {% endblock %}
 
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/plugin/panel_content.html b/InvenTree/templates/plugin/panel_content.html
new file mode 100644
index 0000000000..6b5c4ed90b
--- /dev/null
+++ b/InvenTree/templates/plugin/panel_content.html
@@ -0,0 +1,19 @@
+{% if plugin_panels %}
+<!-- Custom panel items, loaded via plugins -->
+{% for panel in plugin_panels %}
+<div class='panel panel-hidden' id='panel-{{ panel.key }}'>
+    <div class='panel-heading'>
+        <div class='d-flex flex-wrap'>
+            <h4>{{ panel.title }}</h4>
+            {% include "spacer.html" %}
+            <div class='btn-group' role='group'>
+                <!-- TODO: Implement custom action buttons for plugin panels -->
+            </div>
+        </div>
+    </div>
+    <div class='panel-content'>
+        {{ panel.content | safe }}
+    </div>
+</div>
+{% endfor %}
+{% endif %}
\ No newline at end of file
diff --git a/InvenTree/templates/plugin/panel_js.html b/InvenTree/templates/plugin/panel_js.html
new file mode 100644
index 0000000000..bf8b7fea34
--- /dev/null
+++ b/InvenTree/templates/plugin/panel_js.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/plugin/panel_menu.html b/InvenTree/templates/plugin/panel_menu.html
new file mode 100644
index 0000000000..bbbadfdc64
--- /dev/null
+++ b/InvenTree/templates/plugin/panel_menu.html
@@ -0,0 +1,6 @@
+{% if plugin_panels %}
+<!-- Custom sidebar menu items, loaded via plugins -->
+{% for panel in plugin_panels %}
+{% include "sidebar_item.html" with label=panel.key text=panel.title icon=panel.icon %}
+{% endfor %}
+{% endif %}
\ No newline at end of file