mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2937 from SchrodingersGat/panel-plugin
[Feature] UI Plugins
This commit is contained in:
commit
64e2234159
@ -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):
|
||||
|
@ -58,7 +58,7 @@ class HelperForm(forms.ModelForm):
|
||||
|
||||
def is_valid(self):
|
||||
|
||||
valid = super(HelperForm, self).is_valid()
|
||||
valid = super().is_valid()
|
||||
|
||||
return valid
|
||||
|
||||
|
@ -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',
|
||||
])
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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 %}
|
||||
|
@ -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" %}
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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)
|
@ -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',
|
||||
|
@ -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
|
||||
|
||||
|
94
InvenTree/plugin/samples/integration/custom_panel_sample.py
Normal file
94
InvenTree/plugin/samples/integration/custom_panel_sample.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""
|
||||
Sample plugin which renders custom panels on certain pages
|
||||
"""
|
||||
|
||||
from plugin import IntegrationPluginBase
|
||||
from plugin.mixins import PanelMixin, SettingsMixin
|
||||
|
||||
from part.views import PartDetail
|
||||
from stock.views import StockLocationDetail
|
||||
|
||||
|
||||
class CustomPanelSample(PanelMixin, SettingsMixin, IntegrationPluginBase):
|
||||
"""
|
||||
A sample plugin which renders some custom panels.
|
||||
"""
|
||||
|
||||
PLUGIN_NAME = "CustomPanelExample"
|
||||
PLUGIN_SLUG = "panel"
|
||||
PLUGIN_TITLE = "Custom Panel Example"
|
||||
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
|
||||
VERSION = "0.1"
|
||||
|
||||
SETTINGS = {
|
||||
'ENABLE_HELLO_WORLD': {
|
||||
'name': 'Hello World',
|
||||
'description': 'Enable a custom hello world panel on every page',
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
}
|
||||
}
|
||||
|
||||
def get_panel_context(self, view, request, context):
|
||||
|
||||
ctx = super().get_panel_context(view, request, context)
|
||||
|
||||
# If we are looking at a StockLocationDetail view, add location context object
|
||||
if isinstance(view, StockLocationDetail):
|
||||
ctx['location'] = view.get_object()
|
||||
|
||||
return ctx
|
||||
|
||||
def get_custom_panels(self, view, request):
|
||||
|
||||
"""
|
||||
You can decide at run-time which custom panels you want to display!
|
||||
|
||||
- Display on every page
|
||||
- Only on a single page or set of pages
|
||||
- Only for a specific instance (e.g. part)
|
||||
- Based on the user viewing the page!
|
||||
"""
|
||||
|
||||
panels = [
|
||||
{
|
||||
# This panel will not be displayed, as it is missing the 'content' key
|
||||
'title': 'No Content',
|
||||
}
|
||||
]
|
||||
|
||||
if self.get_setting('ENABLE_HELLO_WORLD'):
|
||||
panels.append({
|
||||
# This 'hello world' panel will be displayed on any view which implements custom panels
|
||||
'title': 'Hello World',
|
||||
'icon': 'fas fa-boxes',
|
||||
'content': '<b>Hello world!</b>',
|
||||
'description': 'A simple panel which renders hello world',
|
||||
'javascript': 'console.log("Hello world, from a custom panel!");',
|
||||
})
|
||||
|
||||
# This panel will *only* display on the PartDetail view
|
||||
if isinstance(view, PartDetail):
|
||||
panels.append({
|
||||
'title': 'Custom Part Panel',
|
||||
'icon': 'fas fa-shapes',
|
||||
'content': '<em>This content only appears on the PartDetail page, you know!</em>',
|
||||
})
|
||||
|
||||
# This panel will *only* display on the StockLocation view,
|
||||
# and *only* if the StockLocation has *no* child locations
|
||||
if isinstance(view, StockLocationDetail):
|
||||
|
||||
try:
|
||||
loc = view.get_object()
|
||||
|
||||
if not loc.get_descendants(include_self=False).exists():
|
||||
panels.append({
|
||||
'title': 'Childless Location',
|
||||
'icon': 'fa-user',
|
||||
'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file!
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
return panels
|
@ -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>
|
62
InvenTree/plugin/template.py
Normal file
62
InvenTree/plugin/template.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
load templates for loaded plugins
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from django import template
|
||||
from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||
|
||||
from plugin import registry
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class PluginTemplateLoader(FilesystemLoader):
|
||||
"""
|
||||
A custom template loader which allows loading of templates from installed plugins.
|
||||
|
||||
Each plugin can register templates simply by providing a 'templates' directory in its root path.
|
||||
|
||||
The convention is that each 'templates' directory contains a subdirectory with the same name as the plugin,
|
||||
e.g. templates/myplugin/my_template.html
|
||||
|
||||
In this case, the template can then be loaded (from any plugin!) by loading "myplugin/my_template.html".
|
||||
|
||||
The separate plugin-named directories help keep the templates separated and uniquely identifiable.
|
||||
"""
|
||||
|
||||
def get_dirs(self):
|
||||
dirname = 'templates'
|
||||
template_dirs = []
|
||||
|
||||
for plugin in registry.plugins.values():
|
||||
new_path = Path(plugin.path) / dirname
|
||||
if Path(new_path).is_dir():
|
||||
template_dirs.append(new_path)
|
||||
|
||||
return tuple(template_dirs)
|
||||
|
||||
|
||||
def render_template(plugin, template_file, context=None):
|
||||
"""
|
||||
Locate and render a template file, available in the global template context.
|
||||
"""
|
||||
|
||||
try:
|
||||
tmp = template.loader.get_template(template_file)
|
||||
except template.TemplateDoesNotExist:
|
||||
logger.error(f"Plugin {plugin.slug} could not locate template '{template_file}'")
|
||||
|
||||
return f"""
|
||||
<div class='alert alert-block alert-danger'>
|
||||
Template file <em>{template_file}</em> does not exist.
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Render with the provided context
|
||||
html = tmp.render(context)
|
||||
|
||||
return html
|
40
InvenTree/plugin/views.py
Normal file
40
InvenTree/plugin/views.py
Normal file
@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
|
||||
class InvenTreePluginViewMixin:
|
||||
"""
|
||||
Custom view mixin which adds context data to the view,
|
||||
based on loaded plugins.
|
||||
|
||||
This allows rendered pages to be augmented by loaded plugins.
|
||||
|
||||
"""
|
||||
|
||||
def get_plugin_panels(self, ctx):
|
||||
"""
|
||||
Return a list of extra 'plugin panels' associated with this view
|
||||
"""
|
||||
|
||||
panels = []
|
||||
|
||||
for plug in registry.with_mixin('panel'):
|
||||
panels += plug.render_panels(self, self.request, ctx)
|
||||
|
||||
return panels
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Add plugin context data to the view
|
||||
"""
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
if settings.PLUGINS_ENABLED:
|
||||
ctx['plugin_panels'] = self.get_plugin_panels(ctx)
|
||||
|
||||
return ctx
|
@ -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
|
||||
"""
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -61,6 +61,8 @@
|
||||
</div>
|
||||
|
||||
{% block page_content %}
|
||||
<!-- Custom page content goes here-->
|
||||
{% endblock %}
|
||||
{% include "plugin/panel_content.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
19
InvenTree/templates/plugin/panel_content.html
Normal file
19
InvenTree/templates/plugin/panel_content.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% if plugin_panels %}
|
||||
<!-- Custom panel items, loaded via plugins -->
|
||||
{% for panel in plugin_panels %}
|
||||
<div class='panel panel-hidden' id='panel-{{ panel.key }}'>
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{{ panel.title }}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
<!-- TODO: Implement custom action buttons for plugin panels -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{{ panel.content | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
10
InvenTree/templates/plugin/panel_js.html
Normal file
10
InvenTree/templates/plugin/panel_js.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% if plugin_panels %}
|
||||
// Run custom javascript when plugin panels are loaded
|
||||
{% for panel in plugin_panels %}
|
||||
{% if panel.javascript %}
|
||||
onPanelLoad('{{ panel.key }}', function() {
|
||||
{{ panel.javascript | safe }}
|
||||
});
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
6
InvenTree/templates/plugin/panel_menu.html
Normal file
6
InvenTree/templates/plugin/panel_menu.html
Normal file
@ -0,0 +1,6 @@
|
||||
{% if plugin_panels %}
|
||||
<!-- Custom sidebar menu items, loaded via plugins -->
|
||||
{% for panel in plugin_panels %}
|
||||
{% include "sidebar_item.html" with label=panel.key text=panel.title icon=panel.icon %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
Loading…
Reference in New Issue
Block a user