diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 7c8f71ea9a..09ec02f158 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -6,6 +6,7 @@ Main JSON interface views from __future__ import unicode_literals from django.utils.translation import ugettext_lazy as _ +from django.conf import settings from django.http import JsonResponse from django_filters.rest_framework import DjangoFilterBackend @@ -37,6 +38,7 @@ class InfoView(AjaxView): 'instance': inventreeInstanceName(), 'apiVersion': inventreeApiVersion(), 'worker_running': is_worker_running(), + 'plugins_enabled': settings.PLUGINS_ENABLED, } return JsonResponse(data) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 6d4848f436..f97f134e4a 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,17 @@ import common.models INVENTREE_SW_VERSION = "0.7.0 dev" # 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 +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 - Adds "parameters" detail to Part API endpoint (use ¶meters=true) - Adds ability to filter PartParameterTemplate API by Part instance diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 21bddfd6ca..0ed3c01f7e 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -258,6 +258,19 @@ + + +
+ + +
+ {% include "filter_list.html" with id='incompletebuilditems' %} {% 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 %} {% if build.active and build.has_untracked_bom_items %} diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 1a9b6f37d4..ad144cd8db 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -79,7 +79,7 @@ class BaseInvenTreeSetting(models.Model): self.key = str(self.key).upper() self.clean(**kwargs) - self.validate_unique() + self.validate_unique(**kwargs) super().save() @@ -230,10 +230,6 @@ class BaseInvenTreeSetting(models.Model): return choices - @classmethod - def get_filters(cls, key, **kwargs): - return {'key__iexact': key} - @classmethod def get_setting_object(cls, key, **kwargs): """ @@ -247,29 +243,35 @@ class BaseInvenTreeSetting(models.Model): settings = cls.objects.all() + filters = { + 'key__iexact': key, + } + # Filter by user user = kwargs.get('user', None) if user is not None: - settings = settings.filter(user=user) + filters['user'] = user - try: - setting = settings.filter(**cls.get_filters(key, **kwargs)).first() - except (ValueError, cls.DoesNotExist): - setting = None - except (IntegrityError, OperationalError): - setting = None + # Filter by plugin + plugin = kwargs.get('plugin', None) - plugin = kwargs.pop('plugin', None) - - if plugin: + if plugin is not None: from plugin import InvenTreePluginBase if issubclass(plugin.__class__, InvenTreePluginBase): plugin = plugin.plugin_config() + filters['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) if not setting: @@ -287,7 +289,7 @@ class BaseInvenTreeSetting(models.Model): try: # Wrap this statement in "atomic", so it can be rolled back if it fails with transaction.atomic(): - setting.save() + setting.save(**kwargs) except (IntegrityError, OperationalError): # It might be the case that the database isn't created yet pass @@ -342,8 +344,26 @@ class BaseInvenTreeSetting(models.Model): if change_user is not None and not change_user.is_staff: 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: - setting = cls.objects.get(**cls.get_filters(key, **kwargs)) + setting = cls.objects.get(**filters) except cls.DoesNotExist: if create: @@ -438,17 +458,37 @@ class BaseInvenTreeSetting(models.Model): validator(self.value) 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' 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) + 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: - 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(): raise ValidationError({'key': _('Key string must be unique')}) + except self.DoesNotExist: pass @@ -1200,6 +1240,13 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'NOTIFICATION_SEND_EMAILS': { + 'name': _('Enable email notifications'), + 'description': _('Allow sending of emails for event notifications'), + 'default': True, + 'validator': bool, + }, + "LABEL_INLINE": { 'name': _('Inline label display'), '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): 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) - @classmethod - def get_filters(cls, key, **kwargs): - return { - 'key__iexact': key, - 'user__id': kwargs['user'].id - } - def to_native_value(self): """ Return the "pythonic" value, diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index 6ba7b6d659..fe737fc919 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -8,15 +8,19 @@ from allauth.account.models import EmailAddress from InvenTree.helpers import inheritors from InvenTree.ready import isImportingData from common.models import NotificationEntry, NotificationMessage +from common.models import InvenTreeUserSetting + import InvenTree.tasks logger = logging.getLogger('inventree') -# region notification classes -# region base classes class NotificationMethod: + """ + Base class for notification methods + """ + METHOD_NAME = '' CONTEXT_BUILTIN = ['name', 'message', ] CONTEXT_EXTRA = [] @@ -95,10 +99,8 @@ class SingleNotificationMethod(NotificationMethod): class BulkNotificationMethod(NotificationMethod): def send_bulk(self): raise NotImplementedError('The `send` method must be overriden!') -# endregion -# region implementations class EmailNotification(BulkNotificationMethod): METHOD_NAME = 'mail' CONTEXT_EXTRA = [ @@ -108,13 +110,26 @@ class EmailNotification(BulkNotificationMethod): ] 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( - user__in=self.targets, + user__in=allowed_users, ) def send_bulk(self): 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) @@ -137,20 +152,27 @@ class UIMessageNotification(SingleNotificationMethod): message=self.context['message'], ) 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(): return # Resolve objekt reference obj_ref_value = getattr(obj, obj_ref) + # Try with some defaults if not obj_ref_value: 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)}'") # 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]]: logger.info(f"Triggering method '{method.METHOD_NAME}'") diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 475d4f8fea..5103d99676 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -1,10 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from io import BytesIO +from PIL import Image + from django.utils.translation import ugettext_lazy as _ + +from django.conf import settings from django.conf.urls import url, include 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 @@ -12,8 +17,11 @@ from rest_framework import generics, filters from rest_framework.response import Response import InvenTree.helpers +from InvenTree.tasks import offload_task import common.models +from plugin.registry import registry + from stock.models import StockItem, StockLocation from part.models import Part @@ -46,11 +54,43 @@ class LabelPrintMixin: 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): """ 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: # No valid items provided, return an error message data = { @@ -66,6 +106,8 @@ class LabelPrintMixin: label_name = "label.pdf" + label_names = [] + # Merge one or more PDF files into a single download for item in items_to_print: label = self.get_object() @@ -73,6 +115,8 @@ class LabelPrintMixin: label_name = label.generate_filename(request) + label_names.append(label_name) + if debug_mode: outputs.append(label.render_as_string(request)) else: @@ -81,7 +125,51 @@ class LabelPrintMixin: if not label_name.endswith(".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, and return the string as a HTML response. @@ -90,6 +178,7 @@ class LabelPrintMixin: html = "\n".join(outputs) return HttpResponse(html) + else: """ Concatenate all rendered pages into a single PDF object, diff --git a/InvenTree/label/templates/label/label_base.html b/InvenTree/label/templates/label/label_base.html index 2986c8a439..363a7d3144 100644 --- a/InvenTree/label/templates/label/label_base.html +++ b/InvenTree/label/templates/label/label_base.html @@ -13,6 +13,8 @@ body { font-family: Arial, Helvetica, sans-serif; margin: 0mm; + color: #000; + background-color: #FFF; } img { diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index 9bc34f83df..b5e02e1128 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -5,7 +5,6 @@ import logging from django.utils.translation import ugettext_lazy as _ - import InvenTree.helpers import InvenTree.tasks import common.notifications diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 3deab1ecd1..dc93e00efa 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -8,6 +8,7 @@ over and above the built-in Django tags. from datetime import date, datetime import os import sys +import logging from django.utils.html import format_html @@ -31,6 +32,9 @@ from plugin.models import PluginSetting register = template.Library() +logger = logging.getLogger('inventree') + + @register.simple_tag() def define(value, *args, **kwargs): """ @@ -57,8 +61,19 @@ def render_date(context, date_object): return None 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 - 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! user_date_format = context.get('user_date_format', None) diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 9ab3b96724..5a4ea7bae3 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -9,6 +9,7 @@ from django.conf.urls import url, include from rest_framework import generics from rest_framework import status +from rest_framework import permissions from rest_framework.response import Response from common.api import GlobalSettingsPermissions @@ -22,6 +23,11 @@ class PluginList(generics.ListAPIView): - 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 queryset = PluginConfig.objects.all() diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 1e6b7e38b7..118f0b775b 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -393,6 +393,42 @@ class AppMixin: 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: """ Mixin that enables easier API calls for a plugin diff --git a/InvenTree/plugin/events.py b/InvenTree/plugin/events.py index 049c8626c5..b510b42683 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -7,12 +7,15 @@ from __future__ import unicode_literals import logging +from django.utils.translation import ugettext_lazy as _ + from django.conf import settings from django.db import transaction from django.db.models.signals import post_save, post_delete from django.dispatch.dispatcher import receiver from common.models import InvenTreeSetting +import common.notifications from InvenTree.ready import canAppAccessDatabase 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}'") - 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) @@ -186,3 +193,46 @@ def after_delete(sender, instance, **kwargs): model=sender.__name__, 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] + ) diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index 8097b0b459..86e5e92f37 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -2,7 +2,8 @@ Utility class to enable simpler imports """ -from ..builtin.integration.mixins import APICallMixin, AppMixin, 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.barcode.mixins import BarcodeMixin @@ -10,6 +11,7 @@ __all__ = [ 'APICallMixin', 'AppMixin', 'EventMixin', + 'LabelPrintingMixin', 'NavigationMixin', 'ScheduleMixin', 'SettingsMixin', diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index e33d452e0a..44eeafd012 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -175,23 +175,6 @@ class PluginSetting(common.models.BaseInvenTreeSetting): 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( PluginConfig, related_name='settings', diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 2db1beb805..3276c82f45 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -10,12 +10,13 @@ import pathlib import logging import os import subprocess + from typing import OrderedDict from importlib import reload from django.apps import apps 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.urls import clear_url_caches from django.contrib import admin @@ -282,6 +283,8 @@ class PluginsRegistry: if not settings.PLUGIN_TESTING: raise error # pragma: no cover plugin_db_setting = None + except (IntegrityError) as error: + logger.error(f"Error initializing plugin: {error}") # Always activate if testing if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active): diff --git a/InvenTree/templates/InvenTree/notifications/inbox.html b/InvenTree/templates/InvenTree/notifications/inbox.html index 7ec2a27b6a..d8f5190860 100644 --- a/InvenTree/templates/InvenTree/notifications/inbox.html +++ b/InvenTree/templates/InvenTree/notifications/inbox.html @@ -10,7 +10,7 @@ {% endblock %} {% block actions %} -
+
{% trans "Mark all as read" %}
diff --git a/InvenTree/templates/InvenTree/notifications/notifications.html b/InvenTree/templates/InvenTree/notifications/notifications.html index 89f10c3e3d..fedf8a1448 100644 --- a/InvenTree/templates/InvenTree/notifications/notifications.html +++ b/InvenTree/templates/InvenTree/notifications/notifications.html @@ -126,8 +126,12 @@ $("#mark-all").on('click', function() { { read: false, }, + { + success: function(response) { + updateNotificationTables(); + } + } ); - updateNotificationTables(); }); loadNotificationTable("#history-table", { diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index ba27187747..dbb526cfd3 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -22,6 +22,7 @@ {% include "InvenTree/settings/user_settings.html" %} {% include "InvenTree/settings/user_homepage.html" %} {% include "InvenTree/settings/user_search.html" %} +{% include "InvenTree/settings/user_notifications.html" %} {% include "InvenTree/settings/user_labels.html" %} {% include "InvenTree/settings/user_reports.html" %} {% include "InvenTree/settings/user_display.html" %} diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index 85e0b4ce94..9df6b0736f 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -14,6 +14,8 @@ {% include "sidebar_item.html" with label='user-home' text=text icon="fa-home" %} {% trans "Search Settings" as text %} {% 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 %} {% include "sidebar_item.html" with label='user-labels' text=text icon="fa-tag" %} {% trans "Reporting" as text %} diff --git a/InvenTree/templates/InvenTree/settings/user_notifications.html b/InvenTree/templates/InvenTree/settings/user_notifications.html new file mode 100644 index 0000000000..4e9889ca69 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/user_notifications.html @@ -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 %} + +
+ + + {% include "InvenTree/settings/setting.html" with key="NOTIFICATION_SEND_EMAILS" icon='fa-envelope' user_setting=True %} + +
+
+ +{% endblock content %} \ No newline at end of file diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 5b416b4d22..201d3c5c9f 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -213,7 +213,7 @@ function createBuildOutput(build_id, options) { success: function(data) { if (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}`; } }, @@ -1025,9 +1025,10 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { } // 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) { @@ -1043,9 +1044,9 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) { quantity += item.quantity; }); - row.allocated = quantity; + row.allocated = parseFloat(quantity.toFixed(15)); - return quantity; + return row.allocated; } function setupCallbacks() { @@ -1642,6 +1643,9 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) { 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 if (remaining > 0) { table_entries += renderBomItemRow(bom_item, remaining); diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js index 1c843917e6..c0f4c2f735 100644 --- a/InvenTree/templates/js/translated/label.js +++ b/InvenTree/templates/js/translated/label.js @@ -14,11 +14,41 @@ */ /* exported + printLabels, printPartLabels, printStockItemLabels, 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) { /** * Print stock item labels for the given stock items @@ -57,14 +87,17 @@ function printStockItemLabels(items) { response, items, { - success: function(pk) { + success: function(data) { + + var pk = data.label; + var href = `/api/label/stock/${pk}/print/?`; items.forEach(function(item) { href += `items[]=${item}&`; }); - window.location.href = href; + printLabels(href, data.plugin); } } ); @@ -73,6 +106,7 @@ function printStockItemLabels(items) { ); } + function printStockLocationLabels(locations) { if (locations.length == 0) { @@ -107,14 +141,17 @@ function printStockLocationLabels(locations) { response, locations, { - success: function(pk) { + success: function(data) { + + var pk = data.label; + var href = `/api/label/location/${pk}/print/?`; locations.forEach(function(location) { href += `locations[]=${location}&`; }); - window.location.href = href; + printLabels(href, data.plugin); } } ); @@ -162,14 +199,17 @@ function printPartLabels(parts) { response, parts, { - success: function(pk) { - var url = `/api/label/part/${pk}/print/?`; + success: function(data) { + + var pk = data.label; + + var href = `/api/label/part/${pk}/print/?`; 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. */ - // If only a single label template is provided, - // just run with that! + // Array of available plugins for label printing + var plugins = []; - if (labels.length == 1) { - if (options.success) { - options.success(labels[0].pk); + // Request a list of available label printing plugins from the server + inventreeGet( + `/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 =` +
+ +
+ +
+
+ `; } - var modal = options.modal || '#modal-form'; var label_list = makeOptionsList( @@ -233,14 +306,15 @@ function selectLabel(labels, items, options={}) {
- ${label_list}
+ ${plugin_selection}
`; openModal({ @@ -255,14 +329,17 @@ function selectLabel(labels, items, options={}) { modalSubmit(modal, function() { - var label = $(modal).find('#id_label'); - - var pk = label.val(); + var label = $(modal).find('#id_label').val(); + var plugin = $(modal).find('#id_plugin').val(); closeModal(modal); if (options.success) { - options.success(pk); + options.success({ + // Return the selected label template and plugin + label: label, + plugin: plugin, + }); } }); } diff --git a/InvenTree/templates/js/translated/notification.js b/InvenTree/templates/js/translated/notification.js index 4da4c2e875..a289dcb8f7 100644 --- a/InvenTree/templates/js/translated/notification.js +++ b/InvenTree/templates/js/translated/notification.js @@ -253,7 +253,7 @@ function openNotificationPanel() { { success: function(response) { if (response.length == 0) { - html = `

{% trans "No unread notifications" %}

`; + html = `

{% trans "No unread notifications" %}

`; } else { // build up items response.forEach(function(item, index) { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index b7f4162621..ade8bc5a0a 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1770,6 +1770,7 @@ function loadStockTable(table, options) { col = { field: 'location_detail.pathstring', title: '{% trans "Location" %}', + sortName: 'location', formatter: function(value, row) { return locationDetail(row); } @@ -1912,172 +1913,8 @@ function loadStockTable(table, options) { original: original, showColumns: true, 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 += ` (${data.length} {% trans "items" %})`; - - 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 "{% trans 'Undefined location' %}"; - } - } 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 = [ '#stock-print-options', '#stock-options', @@ -2092,7 +1929,6 @@ function loadStockTable(table, options) { buttons, ); - function stockAdjustment(action) { var items = $(table).bootstrapTable('getSelections'); diff --git a/docker/requirements.txt b/docker/requirements.txt index cd27bdb5ce..192d6dac6f 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -1,7 +1,7 @@ # Base python requirements for docker containers # Basic package requirements -setuptools>=57.4.0 +setuptools>=57.4.0,<=60.1.0 wheel>=0.37.0 invoke>=1.4.0 # Invoke build tool