Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue2694

This commit is contained in:
Matthias 2022-03-27 23:19:41 +02:00
commit e1b3de001f
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
25 changed files with 489 additions and 259 deletions

View File

@ -6,6 +6,7 @@ Main JSON interface views
from __future__ import unicode_literals from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -37,6 +38,7 @@ class InfoView(AjaxView):
'instance': inventreeInstanceName(), 'instance': inventreeInstanceName(),
'apiVersion': inventreeApiVersion(), 'apiVersion': inventreeApiVersion(),
'worker_running': is_worker_running(), 'worker_running': is_worker_running(),
'plugins_enabled': settings.PLUGINS_ENABLED,
} }
return JsonResponse(data) return JsonResponse(data)

View File

@ -12,11 +12,17 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev" INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 32 INVENTREE_API_VERSION = 34
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v34 -> 2022-03-25
- Change permissions for "plugin list" API endpoint (now allows any authenticated user)
v33 -> 2022-03-24
- Adds "plugins_enabled" information to root API endpoint
v32 -> 2022-03-19 v32 -> 2022-03-19
- Adds "parameters" detail to Part API endpoint (use &parameters=true) - Adds "parameters" detail to Part API endpoint (use &parameters=true)
- Adds ability to filter PartParameterTemplate API by Part instance - Adds ability to filter PartParameterTemplate API by Part instance

View File

@ -258,6 +258,19 @@
</a></li> </a></li>
</ul> </ul>
</div> </div>
<!-- Label Printing Actions -->
<div class='btn-group'>
<button id='output-print-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Printing Actions" %}'>
<span class='fas fa-print'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='incomplete-output-print-label' title='{% trans "Print labels" %}'>
<span class='fas fa-tags'></span> {% trans "Print labels" %}
</a></li>
</ul>
</div>
{% include "filter_list.html" with id='incompletebuilditems' %} {% include "filter_list.html" with id='incompletebuilditems' %}
</div> </div>
{% endif %} {% endif %}
@ -468,6 +481,23 @@ inventreeGet(
) )
}); });
$('#incomplete-output-print-label').click(function() {
var outputs = $('#build-output-table').bootstrapTable('getSelections');
if (outputs.length == 0) {
outputs = $('#build-output-table').bootstrapTable('getData');
}
var stock_id_values = [];
outputs.forEach(function(output) {
stock_id_values.push(output.pk);
});
printStockItemLabels(stock_id_values);
});
{% endif %} {% endif %}
{% if build.active and build.has_untracked_bom_items %} {% if build.active and build.has_untracked_bom_items %}

View File

@ -79,7 +79,7 @@ class BaseInvenTreeSetting(models.Model):
self.key = str(self.key).upper() self.key = str(self.key).upper()
self.clean(**kwargs) self.clean(**kwargs)
self.validate_unique() self.validate_unique(**kwargs)
super().save() super().save()
@ -230,10 +230,6 @@ class BaseInvenTreeSetting(models.Model):
return choices return choices
@classmethod
def get_filters(cls, key, **kwargs):
return {'key__iexact': key}
@classmethod @classmethod
def get_setting_object(cls, key, **kwargs): def get_setting_object(cls, key, **kwargs):
""" """
@ -247,29 +243,35 @@ class BaseInvenTreeSetting(models.Model):
settings = cls.objects.all() settings = cls.objects.all()
filters = {
'key__iexact': key,
}
# Filter by user # Filter by user
user = kwargs.get('user', None) user = kwargs.get('user', None)
if user is not None: if user is not None:
settings = settings.filter(user=user) filters['user'] = user
try: # Filter by plugin
setting = settings.filter(**cls.get_filters(key, **kwargs)).first() plugin = kwargs.get('plugin', None)
except (ValueError, cls.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
setting = None
plugin = kwargs.pop('plugin', None) if plugin is not None:
if plugin:
from plugin import InvenTreePluginBase from plugin import InvenTreePluginBase
if issubclass(plugin.__class__, InvenTreePluginBase): if issubclass(plugin.__class__, InvenTreePluginBase):
plugin = plugin.plugin_config() plugin = plugin.plugin_config()
filters['plugin'] = plugin
kwargs['plugin'] = plugin kwargs['plugin'] = plugin
try:
setting = settings.filter(**filters).first()
except (ValueError, cls.DoesNotExist):
setting = None
except (IntegrityError, OperationalError):
setting = None
# Setting does not exist! (Try to create it) # Setting does not exist! (Try to create it)
if not setting: if not setting:
@ -287,7 +289,7 @@ class BaseInvenTreeSetting(models.Model):
try: try:
# Wrap this statement in "atomic", so it can be rolled back if it fails # Wrap this statement in "atomic", so it can be rolled back if it fails
with transaction.atomic(): with transaction.atomic():
setting.save() setting.save(**kwargs)
except (IntegrityError, OperationalError): except (IntegrityError, OperationalError):
# It might be the case that the database isn't created yet # It might be the case that the database isn't created yet
pass pass
@ -342,8 +344,26 @@ class BaseInvenTreeSetting(models.Model):
if change_user is not None and not change_user.is_staff: if change_user is not None and not change_user.is_staff:
return return
filters = {
'key__iexact': key,
}
user = kwargs.get('user', None)
plugin = kwargs.get('plugin', None)
if user is not None:
filters['user'] = user
if plugin is not None:
from plugin import InvenTreePluginBase
if issubclass(plugin.__class__, InvenTreePluginBase):
filters['plugin'] = plugin.plugin_config()
else:
filters['plugin'] = plugin
try: try:
setting = cls.objects.get(**cls.get_filters(key, **kwargs)) setting = cls.objects.get(**filters)
except cls.DoesNotExist: except cls.DoesNotExist:
if create: if create:
@ -438,17 +458,37 @@ class BaseInvenTreeSetting(models.Model):
validator(self.value) validator(self.value)
def validate_unique(self, exclude=None, **kwargs): def validate_unique(self, exclude=None, **kwargs):
""" Ensure that the key:value pair is unique. """
Ensure that the key:value pair is unique.
In addition to the base validators, this ensures that the 'key' In addition to the base validators, this ensures that the 'key'
is unique, using a case-insensitive comparison. is unique, using a case-insensitive comparison.
Note that sub-classes (UserSetting, PluginSetting) use other filters
to determine if the setting is 'unique' or not
""" """
super().validate_unique(exclude) super().validate_unique(exclude)
filters = {
'key__iexact': self.key,
}
user = getattr(self, 'user', None)
plugin = getattr(self, 'plugin', None)
if user is not None:
filters['user'] = user
if plugin is not None:
filters['plugin'] = plugin
try: try:
setting = self.__class__.objects.exclude(id=self.id).filter(**self.get_filters(self.key, **kwargs)) # Check if a duplicate setting already exists
setting = self.__class__.objects.filter(**filters).exclude(id=self.id)
if setting.exists(): if setting.exists():
raise ValidationError({'key': _('Key string must be unique')}) raise ValidationError({'key': _('Key string must be unique')})
except self.DoesNotExist: except self.DoesNotExist:
pass pass
@ -1200,6 +1240,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'validator': bool, 'validator': bool,
}, },
'NOTIFICATION_SEND_EMAILS': {
'name': _('Enable email notifications'),
'description': _('Allow sending of emails for event notifications'),
'default': True,
'validator': bool,
},
"LABEL_INLINE": { "LABEL_INLINE": {
'name': _('Inline label display'), 'name': _('Inline label display'),
'description': _('Display PDF labels in the browser, instead of downloading as a file'), 'description': _('Display PDF labels in the browser, instead of downloading as a file'),
@ -1305,16 +1352,9 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
def get_setting_object(cls, key, user): def get_setting_object(cls, key, user):
return super().get_setting_object(key, user=user) return super().get_setting_object(key, user=user)
def validate_unique(self, exclude=None): def validate_unique(self, exclude=None, **kwargs):
return super().validate_unique(exclude=exclude, user=self.user) return super().validate_unique(exclude=exclude, user=self.user)
@classmethod
def get_filters(cls, key, **kwargs):
return {
'key__iexact': key,
'user__id': kwargs['user'].id
}
def to_native_value(self): def to_native_value(self):
""" """
Return the "pythonic" value, Return the "pythonic" value,

View File

@ -8,15 +8,19 @@ from allauth.account.models import EmailAddress
from InvenTree.helpers import inheritors from InvenTree.helpers import inheritors
from InvenTree.ready import isImportingData from InvenTree.ready import isImportingData
from common.models import NotificationEntry, NotificationMessage from common.models import NotificationEntry, NotificationMessage
from common.models import InvenTreeUserSetting
import InvenTree.tasks import InvenTree.tasks
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
# region notification classes
# region base classes
class NotificationMethod: class NotificationMethod:
"""
Base class for notification methods
"""
METHOD_NAME = '' METHOD_NAME = ''
CONTEXT_BUILTIN = ['name', 'message', ] CONTEXT_BUILTIN = ['name', 'message', ]
CONTEXT_EXTRA = [] CONTEXT_EXTRA = []
@ -95,10 +99,8 @@ class SingleNotificationMethod(NotificationMethod):
class BulkNotificationMethod(NotificationMethod): class BulkNotificationMethod(NotificationMethod):
def send_bulk(self): def send_bulk(self):
raise NotImplementedError('The `send` method must be overriden!') raise NotImplementedError('The `send` method must be overriden!')
# endregion
# region implementations
class EmailNotification(BulkNotificationMethod): class EmailNotification(BulkNotificationMethod):
METHOD_NAME = 'mail' METHOD_NAME = 'mail'
CONTEXT_EXTRA = [ CONTEXT_EXTRA = [
@ -108,13 +110,26 @@ class EmailNotification(BulkNotificationMethod):
] ]
def get_targets(self): def get_targets(self):
"""
Return a list of target email addresses,
only for users which allow email notifications
"""
allowed_users = []
for user in self.targets:
allows_emails = InvenTreeUserSetting.get_setting('NOTIFICATION_SEND_EMAILS', user=user)
if allows_emails:
allowed_users.append(user)
return EmailAddress.objects.filter( return EmailAddress.objects.filter(
user__in=self.targets, user__in=allowed_users,
) )
def send_bulk(self): def send_bulk(self):
html_message = render_to_string(self.context['template']['html'], self.context) html_message = render_to_string(self.context['template']['html'], self.context)
targets = self.targets.values_list('email', flat=True) targets = self.get_targets().values_list('email', flat=True)
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message) InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message)
@ -137,20 +152,27 @@ class UIMessageNotification(SingleNotificationMethod):
message=self.context['message'], message=self.context['message'],
) )
return True return True
# endregion
# endregion
def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_fnc=None, target_args=[], target_kwargs={}, context={}): def trigger_notifaction(obj, category=None, obj_ref='pk', **kwargs):
""" """
Send out an notification Send out a notification
""" """
# check if data is importet currently
targets = kwargs.get('targets', None)
target_fnc = kwargs.get('target_fnc', None)
target_args = kwargs.get('target_args', [])
target_kwargs = kwargs.get('target_kwargs', {})
context = kwargs.get('context', {})
delivery_methods = kwargs.get('delivery_methods', None)
# Check if data is importing currently
if isImportingData(): if isImportingData():
return return
# Resolve objekt reference # Resolve objekt reference
obj_ref_value = getattr(obj, obj_ref) obj_ref_value = getattr(obj, obj_ref)
# Try with some defaults # Try with some defaults
if not obj_ref_value: if not obj_ref_value:
obj_ref_value = getattr(obj, 'pk') obj_ref_value = getattr(obj, 'pk')
@ -175,7 +197,8 @@ def trigger_notifaction(obj, category=None, obj_ref='pk', targets=None, target_f
logger.info(f"Sending notification '{category}' for '{str(obj)}'") logger.info(f"Sending notification '{category}' for '{str(obj)}'")
# Collect possible methods # Collect possible methods
delivery_methods = inheritors(NotificationMethod) if delivery_methods is None:
delivery_methods = inheritors(NotificationMethod)
for method in [a for a in delivery_methods if a not in [SingleNotificationMethod, BulkNotificationMethod]]: for method in [a for a in delivery_methods if a not in [SingleNotificationMethod, BulkNotificationMethod]]:
logger.info(f"Triggering method '{method.METHOD_NAME}'") logger.info(f"Triggering method '{method.METHOD_NAME}'")

View File

@ -1,10 +1,15 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from io import BytesIO
from PIL import Image
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.conf.urls import url, include from django.conf.urls import url, include
from django.core.exceptions import ValidationError, FieldError from django.core.exceptions import ValidationError, FieldError
from django.http import HttpResponse from django.http import HttpResponse, JsonResponse
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -12,8 +17,11 @@ from rest_framework import generics, filters
from rest_framework.response import Response from rest_framework.response import Response
import InvenTree.helpers import InvenTree.helpers
from InvenTree.tasks import offload_task
import common.models import common.models
from plugin.registry import registry
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from part.models import Part from part.models import Part
@ -46,11 +54,43 @@ class LabelPrintMixin:
Mixin for printing labels Mixin for printing labels
""" """
def get_plugin(self, request):
"""
Return the label printing plugin associated with this request.
This is provided in the url, e.g. ?plugin=myprinter
Requires:
- settings.PLUGINS_ENABLED is True
- matching plugin can be found
- matching plugin implements the 'labels' mixin
- matching plugin is enabled
"""
if not settings.PLUGINS_ENABLED:
return None
plugin_key = request.query_params.get('plugin', None)
for slug, plugin in registry.plugins.items():
if slug == plugin_key and plugin.mixin_enabled('labels'):
config = plugin.plugin_config()
if config and config.active:
# Only return the plugin if it is enabled!
return plugin
# No matches found
return None
def print(self, request, items_to_print): def print(self, request, items_to_print):
""" """
Print this label template against a number of pre-validated items Print this label template against a number of pre-validated items
""" """
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(request)
if len(items_to_print) == 0: if len(items_to_print) == 0:
# No valid items provided, return an error message # No valid items provided, return an error message
data = { data = {
@ -66,6 +106,8 @@ class LabelPrintMixin:
label_name = "label.pdf" label_name = "label.pdf"
label_names = []
# Merge one or more PDF files into a single download # Merge one or more PDF files into a single download
for item in items_to_print: for item in items_to_print:
label = self.get_object() label = self.get_object()
@ -73,6 +115,8 @@ class LabelPrintMixin:
label_name = label.generate_filename(request) label_name = label.generate_filename(request)
label_names.append(label_name)
if debug_mode: if debug_mode:
outputs.append(label.render_as_string(request)) outputs.append(label.render_as_string(request))
else: else:
@ -81,7 +125,51 @@ class LabelPrintMixin:
if not label_name.endswith(".pdf"): if not label_name.endswith(".pdf"):
label_name += ".pdf" label_name += ".pdf"
if debug_mode: if plugin is not None:
"""
Label printing is to be handled by a plugin,
rather than being exported to PDF.
In this case, we do the following:
- Individually generate each label, exporting as an image file
- Pass all the images through to the label printing plugin
- Return a JSON response indicating that the printing has been offloaded
"""
# Label instance
label_instance = self.get_object()
for output in outputs:
"""
For each output, we generate a temporary image file,
which will then get sent to the printer
"""
# Generate a png image at 300dpi
(img_data, w, h) = output.get_document().write_png(resolution=300)
# Construct a BytesIO object, which can be read by pillow
img_bytes = BytesIO(img_data)
image = Image.open(img_bytes)
# Offload a background task to print the provided label
offload_task(
'plugin.events.print_label',
plugin.plugin_slug(),
image,
label_instance=label_instance,
user=request.user,
)
return JsonResponse({
'plugin': plugin.plugin_slug(),
'labels': label_names,
})
elif debug_mode:
""" """
Contatenate all rendered templates into a single HTML string, Contatenate all rendered templates into a single HTML string,
and return the string as a HTML response. and return the string as a HTML response.
@ -90,6 +178,7 @@ class LabelPrintMixin:
html = "\n".join(outputs) html = "\n".join(outputs)
return HttpResponse(html) return HttpResponse(html)
else: else:
""" """
Concatenate all rendered pages into a single PDF object, Concatenate all rendered pages into a single PDF object,

View File

@ -13,6 +13,8 @@
body { body {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
margin: 0mm; margin: 0mm;
color: #000;
background-color: #FFF;
} }
img { img {

View File

@ -5,7 +5,6 @@ import logging
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
import InvenTree.helpers import InvenTree.helpers
import InvenTree.tasks import InvenTree.tasks
import common.notifications import common.notifications

View File

@ -8,6 +8,7 @@ over and above the built-in Django tags.
from datetime import date, datetime from datetime import date, datetime
import os import os
import sys import sys
import logging
from django.utils.html import format_html from django.utils.html import format_html
@ -31,6 +32,9 @@ from plugin.models import PluginSetting
register = template.Library() register = template.Library()
logger = logging.getLogger('inventree')
@register.simple_tag() @register.simple_tag()
def define(value, *args, **kwargs): def define(value, *args, **kwargs):
""" """
@ -57,8 +61,19 @@ def render_date(context, date_object):
return None return None
if type(date_object) == str: if type(date_object) == str:
date_object = date_object.strip()
# Check for empty string
if len(date_object) == 0:
return None
# If a string is passed, first convert it to a datetime # If a string is passed, first convert it to a datetime
date_object = date.fromisoformat(date_object) try:
date_object = date.fromisoformat(date_object)
except ValueError:
logger.warning(f"Tried to convert invalid date string: {date_object}")
return None
# We may have already pre-cached the date format by calling this already! # We may have already pre-cached the date format by calling this already!
user_date_format = context.get('user_date_format', None) user_date_format = context.get('user_date_format', None)

View File

@ -9,6 +9,7 @@ from django.conf.urls import url, include
from rest_framework import generics from rest_framework import generics
from rest_framework import status from rest_framework import status
from rest_framework import permissions
from rest_framework.response import Response from rest_framework.response import Response
from common.api import GlobalSettingsPermissions from common.api import GlobalSettingsPermissions
@ -22,6 +23,11 @@ class PluginList(generics.ListAPIView):
- GET: Return a list of all PluginConfig objects - GET: Return a list of all PluginConfig objects
""" """
# Allow any logged in user to read this endpoint
# This is necessary to allow certain functionality,
# e.g. determining which label printing plugins are available
permission_classes = [permissions.IsAuthenticated]
serializer_class = PluginSerializers.PluginConfigSerializer serializer_class = PluginSerializers.PluginConfigSerializer
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()

View File

@ -393,6 +393,42 @@ class AppMixin:
return True return True
class LabelPrintingMixin:
"""
Mixin which enables direct printing of stock labels.
Each plugin must provide a PLUGIN_NAME attribute, which is used to uniquely identify the printer.
The plugin must also implement the print_label() function
"""
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Label printing'
def __init__(self):
super().__init__()
self.add_mixin('labels', True, __class__)
def print_label(self, label, **kwargs):
"""
Callback to print a single label
Arguments:
label: A black-and-white pillow Image object
kwargs:
length: The length of the label (in mm)
width: The width of the label (in mm)
"""
# Unimplemented (to be implemented by the particular plugin class)
...
class APICallMixin: class APICallMixin:
""" """
Mixin that enables easier API calls for a plugin Mixin that enables easier API calls for a plugin

View File

@ -7,12 +7,15 @@ from __future__ import unicode_literals
import logging import logging
from django.utils.translation import ugettext_lazy as _
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch.dispatcher import receiver from django.dispatch.dispatcher import receiver
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
import common.notifications
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
@ -95,7 +98,11 @@ def process_event(plugin_slug, event, *args, **kwargs):
logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'") logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'")
plugin = registry.plugins[plugin_slug] plugin = registry.plugins.get(plugin_slug, None)
if plugin is None:
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return
plugin.process_event(event, *args, **kwargs) plugin.process_event(event, *args, **kwargs)
@ -186,3 +193,46 @@ def after_delete(sender, instance, **kwargs):
model=sender.__name__, model=sender.__name__,
table=table, table=table,
) )
def print_label(plugin_slug, label_image, label_instance=None, user=None):
"""
Print label with the provided plugin.
This task is nominally handled by the background worker.
If the printing fails (throws an exception) then the user is notified.
Arguments:
plugin_slug: The unique slug (key) of the plugin
label_image: A PIL.Image image object to be printed
"""
logger.info(f"Plugin '{plugin_slug}' is printing a label")
plugin = registry.plugins.get(plugin_slug, None)
if plugin is None:
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return
try:
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
except Exception as e:
# Plugin threw an error - notify the user who attempted to print
ctx = {
'name': _('Label printing failed'),
'message': str(e),
}
logger.error(f"Label printing failed: Sending notification to user '{user}'")
# Throw an error against the plugin instance
common.notifications.trigger_notifaction(
plugin.plugin_config(),
'label.printing_failed',
targets=[user],
context=ctx,
delivery_methods=[common.notifications.UIMessageNotification]
)

View File

@ -2,7 +2,8 @@
Utility class to enable simpler imports Utility class to enable simpler imports
""" """
from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from ..builtin.action.mixins import ActionMixin from ..builtin.action.mixins import ActionMixin
from ..builtin.barcode.mixins import BarcodeMixin from ..builtin.barcode.mixins import BarcodeMixin
@ -10,6 +11,7 @@ __all__ = [
'APICallMixin', 'APICallMixin',
'AppMixin', 'AppMixin',
'EventMixin', 'EventMixin',
'LabelPrintingMixin',
'NavigationMixin', 'NavigationMixin',
'ScheduleMixin', 'ScheduleMixin',
'SettingsMixin', 'SettingsMixin',

View File

@ -175,23 +175,6 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
return super().get_setting_definition(key, **kwargs) return super().get_setting_definition(key, **kwargs)
@classmethod
def get_filters(cls, key, **kwargs):
"""
Override filters method to ensure settings are filtered by plugin id
"""
filters = super().get_filters(key, **kwargs)
plugin = kwargs.get('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePluginBase):
plugin = plugin.plugin_config()
filters['plugin'] = plugin
return filters
plugin = models.ForeignKey( plugin = models.ForeignKey(
PluginConfig, PluginConfig,
related_name='settings', related_name='settings',

View File

@ -10,12 +10,13 @@ import pathlib
import logging import logging
import os import os
import subprocess import subprocess
from typing import OrderedDict from typing import OrderedDict
from importlib import reload from importlib import reload
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError, IntegrityError
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import clear_url_caches from django.urls import clear_url_caches
from django.contrib import admin from django.contrib import admin
@ -282,6 +283,8 @@ class PluginsRegistry:
if not settings.PLUGIN_TESTING: if not settings.PLUGIN_TESTING:
raise error # pragma: no cover raise error # pragma: no cover
plugin_db_setting = None plugin_db_setting = None
except (IntegrityError) as error:
logger.error(f"Error initializing plugin: {error}")
# Always activate if testing # Always activate if testing
if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active): if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):

View File

@ -10,7 +10,7 @@
{% endblock %} {% endblock %}
{% block actions %} {% block actions %}
<div class='btn btn-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'> <div class='btn btn-outline-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'>
<span class='fa fa-bookmark'></span> {% trans "Mark all as read" %} <span class='fa fa-bookmark'></span> {% trans "Mark all as read" %}
</div> </div>
<div class='btn btn-secondary' type='button' id='inbox-refresh' title='{% trans "Refresh Pending Notifications" %}'> <div class='btn btn-secondary' type='button' id='inbox-refresh' title='{% trans "Refresh Pending Notifications" %}'>

View File

@ -126,8 +126,12 @@ $("#mark-all").on('click', function() {
{ {
read: false, read: false,
}, },
{
success: function(response) {
updateNotificationTables();
}
}
); );
updateNotificationTables();
}); });
loadNotificationTable("#history-table", { loadNotificationTable("#history-table", {

View File

@ -22,6 +22,7 @@
{% include "InvenTree/settings/user_settings.html" %} {% include "InvenTree/settings/user_settings.html" %}
{% include "InvenTree/settings/user_homepage.html" %} {% include "InvenTree/settings/user_homepage.html" %}
{% include "InvenTree/settings/user_search.html" %} {% include "InvenTree/settings/user_search.html" %}
{% include "InvenTree/settings/user_notifications.html" %}
{% include "InvenTree/settings/user_labels.html" %} {% include "InvenTree/settings/user_labels.html" %}
{% include "InvenTree/settings/user_reports.html" %} {% include "InvenTree/settings/user_reports.html" %}
{% include "InvenTree/settings/user_display.html" %} {% include "InvenTree/settings/user_display.html" %}

View File

@ -14,6 +14,8 @@
{% include "sidebar_item.html" with label='user-home' text=text icon="fa-home" %} {% include "sidebar_item.html" with label='user-home' text=text icon="fa-home" %}
{% trans "Search Settings" as text %} {% trans "Search Settings" as text %}
{% include "sidebar_item.html" with label='user-search' text=text icon="fa-search" %} {% include "sidebar_item.html" with label='user-search' text=text icon="fa-search" %}
{% trans "Notifications" as text %}
{% include "sidebar_item.html" with label='user-notifications' text=text icon="fa-bell" %}
{% trans "Label Printing" as text %} {% trans "Label Printing" as text %}
{% include "sidebar_item.html" with label='user-labels' text=text icon="fa-tag" %} {% include "sidebar_item.html" with label='user-labels' text=text icon="fa-tag" %}
{% trans "Reporting" as text %} {% trans "Reporting" as text %}

View File

@ -0,0 +1,20 @@
{% extends "panel.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block label %}user-notifications{% endblock label %}
{% block heading %}{% trans "Notification Settings" %}{% endblock heading %}
{% block content %}
<div class='row'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="NOTIFICATION_SEND_EMAILS" icon='fa-envelope' user_setting=True %}
</tbody>
</table>
</div>
{% endblock content %}

View File

@ -213,7 +213,7 @@ function createBuildOutput(build_id, options) {
success: function(data) { success: function(data) {
if (data.next) { if (data.next) {
fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`; fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`;
} else { } else if (data.latest) {
fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`; fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`;
} }
}, },
@ -1025,9 +1025,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
} }
// Store the required quantity in the row data // Store the required quantity in the row data
row.required = quantity; // Prevent weird rounding issues
row.required = parseFloat(quantity.toFixed(15));
return quantity; return row.required;
} }
function sumAllocations(row) { function sumAllocations(row) {
@ -1043,9 +1044,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
quantity += item.quantity; quantity += item.quantity;
}); });
row.allocated = quantity; row.allocated = parseFloat(quantity.toFixed(15));
return quantity; return row.allocated;
} }
function setupCallbacks() { function setupCallbacks() {
@ -1642,6 +1643,9 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
remaining = 0; remaining = 0;
} }
// Ensure the quantity sent to the form field is correctly formatted
remaining = parseFloat(remaining.toFixed(15));
// We only care about entries which are not yet fully allocated // We only care about entries which are not yet fully allocated
if (remaining > 0) { if (remaining > 0) {
table_entries += renderBomItemRow(bom_item, remaining); table_entries += renderBomItemRow(bom_item, remaining);

View File

@ -14,11 +14,41 @@
*/ */
/* exported /* exported
printLabels,
printPartLabels, printPartLabels,
printStockItemLabels, printStockItemLabels,
printStockLocationLabels, printStockLocationLabels,
*/ */
/*
* Perform the "print" action.
*/
function printLabels(url, plugin=null) {
if (plugin) {
// If a plugin is provided, do not redirect the browser.
// Instead, perform an API request and display a message
url = url + `plugin=${plugin}`;
inventreeGet(url, {}, {
success: function(response) {
showMessage(
'{% trans "Labels sent to printer" %}',
{
style: 'success',
}
);
}
});
} else {
window.location.href = url;
}
}
function printStockItemLabels(items) { function printStockItemLabels(items) {
/** /**
* Print stock item labels for the given stock items * Print stock item labels for the given stock items
@ -57,14 +87,17 @@ function printStockItemLabels(items) {
response, response,
items, items,
{ {
success: function(pk) { success: function(data) {
var pk = data.label;
var href = `/api/label/stock/${pk}/print/?`; var href = `/api/label/stock/${pk}/print/?`;
items.forEach(function(item) { items.forEach(function(item) {
href += `items[]=${item}&`; href += `items[]=${item}&`;
}); });
window.location.href = href; printLabels(href, data.plugin);
} }
} }
); );
@ -73,6 +106,7 @@ function printStockItemLabels(items) {
); );
} }
function printStockLocationLabels(locations) { function printStockLocationLabels(locations) {
if (locations.length == 0) { if (locations.length == 0) {
@ -107,14 +141,17 @@ function printStockLocationLabels(locations) {
response, response,
locations, locations,
{ {
success: function(pk) { success: function(data) {
var pk = data.label;
var href = `/api/label/location/${pk}/print/?`; var href = `/api/label/location/${pk}/print/?`;
locations.forEach(function(location) { locations.forEach(function(location) {
href += `locations[]=${location}&`; href += `locations[]=${location}&`;
}); });
window.location.href = href; printLabels(href, data.plugin);
} }
} }
); );
@ -162,14 +199,17 @@ function printPartLabels(parts) {
response, response,
parts, parts,
{ {
success: function(pk) { success: function(data) {
var url = `/api/label/part/${pk}/print/?`;
var pk = data.label;
var href = `/api/label/part/${pk}/print/?`;
parts.forEach(function(part) { parts.forEach(function(part) {
url += `parts[]=${part}&`; href += `parts[]=${part}&`;
}); });
window.location.href = url; printLabels(href, data.plugin);
} }
} }
); );
@ -188,18 +228,51 @@ function selectLabel(labels, items, options={}) {
* (via AJAX) from the server. * (via AJAX) from the server.
*/ */
// If only a single label template is provided, // Array of available plugins for label printing
// just run with that! var plugins = [];
if (labels.length == 1) { // Request a list of available label printing plugins from the server
if (options.success) { inventreeGet(
options.success(labels[0].pk); `/api/plugin/`,
{},
{
async: false,
success: function(response) {
response.forEach(function(plugin) {
// Look for active plugins which implement the 'labels' mixin class
if (plugin.active && plugin.mixins && plugin.mixins.labels) {
// This plugin supports label printing
plugins.push(plugin);
}
});
}
} }
);
return; var plugin_selection = '';
if (plugins.length > 0) {
plugin_selection =`
<div class='form-group'>
<label class='control-label requiredField' for='id_plugin'>
{% trans "Select Printer" %}
</label>
<div class='controls'>
<select id='id_plugin' class='select form-control' name='plugin'>
<option value='' title='{% trans "Export to PDF" %}'>{% trans "Export to PDF" %}</option>
`;
plugins.forEach(function(plugin) {
plugin_selection += `<option value='${plugin.key}' title='${plugin.meta.human_name}'>${plugin.name} - <small>${plugin.meta.human_name}</small></option>`;
});
plugin_selection += `
</select>
</div>
</div>
`;
} }
var modal = options.modal || '#modal-form'; var modal = options.modal || '#modal-form';
var label_list = makeOptionsList( var label_list = makeOptionsList(
@ -233,14 +306,15 @@ function selectLabel(labels, items, options={}) {
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'> <form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
<div class='form-group'> <div class='form-group'>
<label class='control-label requiredField' for='id_label'> <label class='control-label requiredField' for='id_label'>
{% trans "Select Label" %} {% trans "Select Label Template" %}
</label> </label>
<div class='controls'> <div class='controls'>
<select id='id_label' class='select form-control name='label'> <select id='id_label' class='select form-control' name='label'>
${label_list} ${label_list}
</select> </select>
</div> </div>
</div> </div>
${plugin_selection}
</form>`; </form>`;
openModal({ openModal({
@ -255,14 +329,17 @@ function selectLabel(labels, items, options={}) {
modalSubmit(modal, function() { modalSubmit(modal, function() {
var label = $(modal).find('#id_label'); var label = $(modal).find('#id_label').val();
var plugin = $(modal).find('#id_plugin').val();
var pk = label.val();
closeModal(modal); closeModal(modal);
if (options.success) { if (options.success) {
options.success(pk); options.success({
// Return the selected label template and plugin
label: label,
plugin: plugin,
});
} }
}); });
} }

View File

@ -253,7 +253,7 @@ function openNotificationPanel() {
{ {
success: function(response) { success: function(response) {
if (response.length == 0) { if (response.length == 0) {
html = `<p class='text-muted'>{% trans "No unread notifications" %}</p>`; html = `<p class='text-muted'><em>{% trans "No unread notifications" %}</em><span class='fas fa-check-circle icon-green float-right'></span></p>`;
} else { } else {
// build up items // build up items
response.forEach(function(item, index) { response.forEach(function(item, index) {

View File

@ -1770,6 +1770,7 @@ function loadStockTable(table, options) {
col = { col = {
field: 'location_detail.pathstring', field: 'location_detail.pathstring',
title: '{% trans "Location" %}', title: '{% trans "Location" %}',
sortName: 'location',
formatter: function(value, row) { formatter: function(value, row) {
return locationDetail(row); return locationDetail(row);
} }
@ -1912,172 +1913,8 @@ function loadStockTable(table, options) {
original: original, original: original,
showColumns: true, showColumns: true,
columns: columns, columns: columns,
{% if False %}
groupByField: options.groupByField || 'part',
groupBy: grouping,
groupByFormatter: function(field, id, data) {
var row = data[0];
if (field == 'part_detail.full_name') {
var html = imageHoverIcon(row.part_detail.thumbnail);
html += row.part_detail.full_name;
html += ` <i>(${data.length} {% trans "items" %})</i>`;
html += makePartIcons(row.part_detail);
return html;
} else if (field == 'part_detail.IPN') {
var ipn = row.part_detail.IPN;
if (ipn) {
return ipn;
} else {
return '-';
}
} else if (field == 'part_detail.description') {
return row.part_detail.description;
} else if (field == 'packaging') {
var packaging = [];
data.forEach(function(item) {
var pkg = item.packaging;
if (!pkg) {
pkg = '-';
}
if (!packaging.includes(pkg)) {
packaging.push(pkg);
}
});
if (packaging.length > 1) {
return "...";
} else if (packaging.length == 1) {
return packaging[0];
} else {
return "-";
}
} else if (field == 'quantity') {
var stock = 0;
var items = 0;
data.forEach(function(item) {
stock += parseFloat(item.quantity);
items += 1;
});
stock = +stock.toFixed(5);
return `${stock} (${items} {% trans "items" %})`;
} else if (field == 'status') {
var statii = [];
data.forEach(function(item) {
var status = String(item.status);
if (!status || status == '') {
status = '-';
}
if (!statii.includes(status)) {
statii.push(status);
}
});
// Multiple status codes
if (statii.length > 1) {
return "...";
} else if (statii.length == 1) {
return stockStatusDisplay(statii[0]);
} else {
return "-";
}
} else if (field == 'batch') {
var batches = [];
data.forEach(function(item) {
var batch = item.batch;
if (!batch || batch == '') {
batch = '-';
}
if (!batches.includes(batch)) {
batches.push(batch);
}
});
if (batches.length > 1) {
return "" + batches.length + " {% trans 'batches' %}";
} else if (batches.length == 1) {
if (batches[0]) {
return batches[0];
} else {
return '-';
}
} else {
return '-';
}
} else if (field == 'location_detail.pathstring') {
/* Determine how many locations */
var locations = [];
data.forEach(function(item) {
var detail = locationDetail(item);
if (!locations.includes(detail)) {
locations.push(detail);
}
});
if (locations.length == 1) {
// Single location, easy!
return locations[0];
} else if (locations.length > 1) {
return "In " + locations.length + " {% trans 'locations' %}";
} else {
return "<i>{% trans 'Undefined location' %}</i>";
}
} else if (field == 'notes') {
var notes = [];
data.forEach(function(item) {
var note = item.notes;
if (!note || note == '') {
note = '-';
}
if (!notes.includes(note)) {
notes.push(note);
}
});
if (notes.length > 1) {
return '...';
} else if (notes.length == 1) {
return notes[0] || '-';
} else {
return '-';
}
} else {
return '';
}
},
{% endif %}
}); });
/*
if (options.buttons) {
linkButtonsToSelection(table, options.buttons);
}
*/
var buttons = [ var buttons = [
'#stock-print-options', '#stock-print-options',
'#stock-options', '#stock-options',
@ -2092,7 +1929,6 @@ function loadStockTable(table, options) {
buttons, buttons,
); );
function stockAdjustment(action) { function stockAdjustment(action) {
var items = $(table).bootstrapTable('getSelections'); var items = $(table).bootstrapTable('getSelections');

View File

@ -1,7 +1,7 @@
# Base python requirements for docker containers # Base python requirements for docker containers
# Basic package requirements # Basic package requirements
setuptools>=57.4.0 setuptools>=57.4.0,<=60.1.0
wheel>=0.37.0 wheel>=0.37.0
invoke>=1.4.0 # Invoke build tool invoke>=1.4.0 # Invoke build tool