mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into theme-tests
This commit is contained in:
commit
061314e989
@ -131,7 +131,7 @@ def round_decimal(value, places):
|
|||||||
|
|
||||||
class RoundingDecimalFormField(forms.DecimalField):
|
class RoundingDecimalFormField(forms.DecimalField):
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
value = super(RoundingDecimalFormField, self).to_python(value)
|
value = super().to_python(value)
|
||||||
value = round_decimal(value, self.decimal_places)
|
value = round_decimal(value, self.decimal_places)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -149,7 +149,7 @@ class RoundingDecimalFormField(forms.DecimalField):
|
|||||||
|
|
||||||
class RoundingDecimalField(models.DecimalField):
|
class RoundingDecimalField(models.DecimalField):
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
value = super(RoundingDecimalField, self).to_python(value)
|
value = super().to_python(value)
|
||||||
return round_decimal(value, self.decimal_places)
|
return round_decimal(value, self.decimal_places)
|
||||||
|
|
||||||
def formfield(self, **kwargs):
|
def formfield(self, **kwargs):
|
||||||
|
@ -58,7 +58,7 @@ class HelperForm(forms.ModelForm):
|
|||||||
|
|
||||||
def is_valid(self):
|
def is_valid(self):
|
||||||
|
|
||||||
valid = super(HelperForm, self).is_valid()
|
valid = super().is_valid()
|
||||||
|
|
||||||
return valid
|
return valid
|
||||||
|
|
||||||
|
@ -343,7 +343,7 @@ TEMPLATES = [
|
|||||||
],
|
],
|
||||||
'loaders': [(
|
'loaders': [(
|
||||||
'django.template.loaders.cached.Loader', [
|
'django.template.loaders.cached.Loader', [
|
||||||
'plugin.loader.PluginTemplateLoader',
|
'plugin.template.PluginTemplateLoader',
|
||||||
'django.template.loaders.filesystem.Loader',
|
'django.template.loaders.filesystem.Loader',
|
||||||
'django.template.loaders.app_directories.Loader',
|
'django.template.loaders.app_directories.Loader',
|
||||||
])
|
])
|
||||||
|
@ -153,28 +153,24 @@ backendpatterns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
frontendpatterns = [
|
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'^common/', include(common_urls)),
|
||||||
|
|
||||||
re_path(r'^stock/', include(stock_urls)),
|
|
||||||
|
|
||||||
re_path(r'^company/', include(company_urls)),
|
re_path(r'^company/', include(company_urls)),
|
||||||
re_path(r'^order/', include(order_urls)),
|
re_path(r'^order/', include(order_urls)),
|
||||||
|
re_path(r'^manufacturer-part/', include(manufacturer_part_urls)),
|
||||||
re_path(r'^build/', include(build_urls)),
|
re_path(r'^part/', include(part_urls)),
|
||||||
|
re_path(r'^stock/', include(stock_urls)),
|
||||||
re_path(r'^settings/', include(settings_urls)),
|
re_path(r'^supplier-part/', include(supplier_part_urls)),
|
||||||
|
|
||||||
re_path(r'^notifications/', include(notifications_urls)),
|
|
||||||
|
|
||||||
re_path(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
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'^set-password/', SetPasswordView.as_view(), name='set-password'),
|
||||||
|
|
||||||
re_path(r'^index/', IndexView.as_view(), name='index'),
|
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'^search/', SearchView.as_view(), name='search'),
|
||||||
|
re_path(r'^settings/', include(settings_urls)),
|
||||||
re_path(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
|
re_path(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
|
||||||
|
|
||||||
# admin sites
|
# admin sites
|
||||||
|
@ -654,7 +654,7 @@ class IndexView(TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
context = super(TemplateView, self).get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@ -849,7 +849,7 @@ class SettingCategorySelectView(FormView):
|
|||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
""" Set category selection """
|
""" Set category selection """
|
||||||
|
|
||||||
initial = super(SettingCategorySelectView, self).get_initial()
|
initial = super().get_initial()
|
||||||
|
|
||||||
category = self.request.GET.get('category', None)
|
category = self.request.GET.get('category', None)
|
||||||
if category:
|
if category:
|
||||||
|
@ -14,6 +14,8 @@ from InvenTree.views import AjaxDeleteView
|
|||||||
from InvenTree.views import InvenTreeRoleMixin
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
|
|
||||||
|
from plugin.views import InvenTreePluginViewMixin
|
||||||
|
|
||||||
|
|
||||||
class BuildIndex(InvenTreeRoleMixin, ListView):
|
class BuildIndex(InvenTreeRoleMixin, ListView):
|
||||||
"""
|
"""
|
||||||
@ -29,7 +31,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
context = super(BuildIndex, self).get_context_data(**kwargs).copy()
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context['BuildStatus'] = BuildStatus
|
context['BuildStatus'] = BuildStatus
|
||||||
|
|
||||||
@ -41,7 +43,7 @@ class BuildIndex(InvenTreeRoleMixin, ListView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class BuildDetail(InvenTreeRoleMixin, DetailView):
|
class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Detail view of a single Build object.
|
Detail view of a single Build object.
|
||||||
"""
|
"""
|
||||||
@ -52,7 +54,7 @@ class BuildDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
ctx = super(DetailView, self).get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
build = self.get_object()
|
build = self.get_object()
|
||||||
|
|
||||||
|
@ -23,9 +23,10 @@ from .models import Company
|
|||||||
from .models import ManufacturerPart
|
from .models import ManufacturerPart
|
||||||
from .models import SupplierPart
|
from .models import SupplierPart
|
||||||
|
|
||||||
|
|
||||||
from .forms import CompanyImageDownloadForm
|
from .forms import CompanyImageDownloadForm
|
||||||
|
|
||||||
|
from plugin.views import InvenTreePluginViewMixin
|
||||||
|
|
||||||
|
|
||||||
class CompanyIndex(InvenTreeRoleMixin, ListView):
|
class CompanyIndex(InvenTreeRoleMixin, ListView):
|
||||||
""" View for displaying list of companies
|
""" View for displaying list of companies
|
||||||
@ -104,7 +105,7 @@ class CompanyIndex(InvenTreeRoleMixin, ListView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class CompanyDetail(DetailView):
|
class CompanyDetail(InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for Company object """
|
""" Detail view for Company object """
|
||||||
context_obect_name = 'company'
|
context_obect_name = 'company'
|
||||||
template_name = 'company/detail.html'
|
template_name = 'company/detail.html'
|
||||||
@ -196,7 +197,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartDetail(DetailView):
|
class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for ManufacturerPart """
|
""" Detail view for ManufacturerPart """
|
||||||
model = ManufacturerPart
|
model = ManufacturerPart
|
||||||
template_name = 'company/manufacturer_part_detail.html'
|
template_name = 'company/manufacturer_part_detail.html'
|
||||||
@ -210,7 +211,7 @@ class ManufacturerPartDetail(DetailView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartDetail(DetailView):
|
class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for SupplierPart """
|
""" Detail view for SupplierPart """
|
||||||
model = SupplierPart
|
model = SupplierPart
|
||||||
template_name = 'company/supplier_part_detail.html'
|
template_name = 'company/supplier_part_detail.html'
|
||||||
|
@ -33,6 +33,8 @@ from part.views import PartPricing
|
|||||||
from InvenTree.helpers import DownloadFile
|
from InvenTree.helpers import DownloadFile
|
||||||
from InvenTree.views import InvenTreeRoleMixin, AjaxView
|
from InvenTree.views import InvenTreeRoleMixin, AjaxView
|
||||||
|
|
||||||
|
from plugin.views import InvenTreePluginViewMixin
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
@ -65,7 +67,7 @@ class SalesOrderIndex(InvenTreeRoleMixin, ListView):
|
|||||||
context_object_name = 'orders'
|
context_object_name = 'orders'
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
|
class PurchaseOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for a PurchaseOrder object """
|
""" Detail view for a PurchaseOrder object """
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
@ -78,7 +80,7 @@ class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
|
class SalesOrderDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for a SalesOrder object """
|
""" Detail view for a SalesOrder object """
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
|
@ -397,7 +397,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<table class='table table-condensed table-striped' id='manufacturer-part-table' data-toolbar='#manufacturer-button-toolbar'></table>
|
<table class='table table-condensed table-striped' id='manufacturer-part-table' data-toolbar='#manufacturer-button-toolbar'></table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -57,4 +57,4 @@
|
|||||||
{% trans "Attachments" as text %}
|
{% trans "Attachments" as text %}
|
||||||
{% include "sidebar_item.html" with label="part-attachments" text=text icon="fa-paperclip" %}
|
{% include "sidebar_item.html" with label="part-attachments" text=text icon="fa-paperclip" %}
|
||||||
{% trans "Notes" as text %}
|
{% 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
|
import part.settings
|
||||||
|
|
||||||
from InvenTree import version
|
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
|
from common.notifications import storage, UIMessageNotification
|
||||||
|
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ class TemplateTagTest(TestCase):
|
|||||||
|
|
||||||
def test_user_settings(self):
|
def test_user_settings(self):
|
||||||
result = inventree_extras.user_settings(self.user)
|
result = inventree_extras.user_settings(self.user)
|
||||||
self.assertEqual(len(result), 35)
|
self.assertEqual(len(result), len(InvenTreeUserSetting.SETTINGS))
|
||||||
|
|
||||||
def test_global_settings(self):
|
def test_global_settings(self):
|
||||||
result = inventree_extras.global_settings()
|
result = inventree_extras.global_settings()
|
||||||
|
@ -53,6 +53,8 @@ from InvenTree.views import InvenTreeRoleMixin
|
|||||||
|
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
|
|
||||||
|
from plugin.views import InvenTreePluginViewMixin
|
||||||
|
|
||||||
|
|
||||||
class PartIndex(InvenTreeRoleMixin, ListView):
|
class PartIndex(InvenTreeRoleMixin, ListView):
|
||||||
""" View for displaying list of Part objects
|
""" View for displaying list of Part objects
|
||||||
@ -67,7 +69,7 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
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
|
# View top-level categories
|
||||||
children = PartCategory.objects.filter(parent=None)
|
children = PartCategory.objects.filter(parent=None)
|
||||||
@ -365,7 +367,7 @@ class PartImportAjax(FileManagementAjaxView, PartImport):
|
|||||||
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
return PartImport.validate(self, self.steps.current, form, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PartDetail(InvenTreeRoleMixin, DetailView):
|
class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for Part object
|
""" Detail view for Part object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -969,7 +971,7 @@ class PartParameterTemplateDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _("Delete Part Parameter Template")
|
ajax_form_title = _("Delete Part Parameter Template")
|
||||||
|
|
||||||
|
|
||||||
class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
class CategoryDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
""" Detail view for PartCategory """
|
""" Detail view for PartCategory """
|
||||||
|
|
||||||
model = PartCategory
|
model = PartCategory
|
||||||
@ -979,7 +981,7 @@ class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
context = super(CategoryDetail, self).get_context_data(**kwargs).copy()
|
context = super().get_context_data(**kwargs).copy()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context['part_count'] = kwargs['object'].partcount()
|
context['part_count'] = kwargs['object'].partcount()
|
||||||
@ -1045,7 +1047,7 @@ class CategoryParameterTemplateCreate(AjaxCreateView):
|
|||||||
- Display parameter templates which are not yet related
|
- Display parameter templates which are not yet related
|
||||||
"""
|
"""
|
||||||
|
|
||||||
form = super(AjaxCreateView, self).get_form()
|
form = super().get_form()
|
||||||
|
|
||||||
form.fields['category'].widget = HiddenInput()
|
form.fields['category'].widget = HiddenInput()
|
||||||
|
|
||||||
@ -1140,7 +1142,7 @@ class CategoryParameterTemplateEdit(AjaxUpdateView):
|
|||||||
- Display parameter templates which are not yet related
|
- 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['category'].widget = HiddenInput()
|
||||||
form.fields['add_to_all_categories'].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.urls import include, re_path
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
|
||||||
from plugin.models import PluginConfig, PluginSetting
|
import InvenTree.helpers
|
||||||
from plugin.urls import PLUGIN_BASE
|
|
||||||
from plugin.helpers import MixinImplementationError, MixinNotImplementedError
|
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')
|
logger = logging.getLogger('inventree')
|
||||||
@ -542,3 +545,120 @@ class APICallMixin:
|
|||||||
if simple_response:
|
if simple_response:
|
||||||
return response.json()
|
return response.json()
|
||||||
return response
|
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
|
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 common.notifications import SingleNotificationMethod, BulkNotificationMethod
|
||||||
|
|
||||||
from ..builtin.action.mixins import ActionMixin
|
from ..builtin.action.mixins import ActionMixin
|
||||||
@ -17,6 +18,7 @@ __all__ = [
|
|||||||
'ScheduleMixin',
|
'ScheduleMixin',
|
||||||
'SettingsMixin',
|
'SettingsMixin',
|
||||||
'UrlsMixin',
|
'UrlsMixin',
|
||||||
|
'PanelMixin',
|
||||||
'ActionMixin',
|
'ActionMixin',
|
||||||
'BarcodeMixin',
|
'BarcodeMixin',
|
||||||
'SingleNotificationMethod',
|
'SingleNotificationMethod',
|
||||||
|
@ -307,14 +307,17 @@ class PluginsRegistry:
|
|||||||
# TODO check more stuff -> as of Nov 2021 there are not many checks in place
|
# 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.
|
# but we could enhance those to check signatures, run the plugin against a whitelist etc.
|
||||||
logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
|
logger.info(f'Loading integration plugin {plugin.PLUGIN_NAME}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plugin = plugin()
|
plugin = plugin()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# log error and raise it -> disable plugin
|
# log error and raise it -> disable plugin
|
||||||
handle_error(error, log_name='init')
|
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
|
plugin.is_package = was_packaged
|
||||||
|
|
||||||
if plugin_db_setting:
|
if plugin_db_setting:
|
||||||
plugin.pk = plugin_db_setting.pk
|
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 . 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
|
""" StockIndex view loads all StockLocation and StockItem object
|
||||||
"""
|
"""
|
||||||
model = StockItem
|
model = StockItem
|
||||||
@ -35,7 +37,7 @@ class StockIndex(InvenTreeRoleMixin, ListView):
|
|||||||
context_obect_name = 'locations'
|
context_obect_name = 'locations'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
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
|
# Return all top-level locations
|
||||||
locations = StockLocation.objects.filter(parent=None)
|
locations = StockLocation.objects.filter(parent=None)
|
||||||
@ -54,7 +56,7 @@ class StockIndex(InvenTreeRoleMixin, ListView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class StockLocationDetail(InvenTreeRoleMixin, DetailView):
|
class StockLocationDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Detailed view of a single StockLocation object
|
Detailed view of a single StockLocation object
|
||||||
"""
|
"""
|
||||||
@ -75,7 +77,7 @@ class StockLocationDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class StockItemDetail(InvenTreeRoleMixin, DetailView):
|
class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||||
"""
|
"""
|
||||||
Detailed view of a single StockItem object
|
Detailed view of a single StockItem object
|
||||||
"""
|
"""
|
||||||
|
@ -69,6 +69,12 @@
|
|||||||
<td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span>
|
<td>{{ plugin.human_name }}<span class="text-muted"> - {{plugin_key}}</span>
|
||||||
{% define plugin.registered_mixins as mixin_list %}
|
{% 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 %}
|
{% if mixin_list %}
|
||||||
{% for mixin in mixin_list %}
|
{% for mixin in mixin_list %}
|
||||||
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
|
<a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
|
||||||
@ -77,12 +83,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% 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 %}
|
{% if plugin.website %}
|
||||||
<a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
|
<a href="{{ plugin.website }}"><span class="fas fa-globe"></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -83,6 +83,7 @@
|
|||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
<!-- Sidebar goes here -->
|
<!-- Sidebar goes here -->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% include "plugin/panel_menu.html" %}
|
||||||
{% include "sidebar_toggle.html" with target='sidebar' %}
|
{% include "sidebar_toggle.html" with target='sidebar' %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -209,6 +210,9 @@
|
|||||||
<script type='text/javascript'>
|
<script type='text/javascript'>
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
|
||||||
|
{% include "plugin/panel_js.html" %}
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -61,6 +61,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
|
<!-- Custom page content goes here-->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% include "plugin/panel_content.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% 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