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..bb36b28ff8 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,14 @@ import common.models INVENTREE_SW_VERSION = "0.7.0 dev" # InvenTree API version -INVENTREE_API_VERSION = 32 +INVENTREE_API_VERSION = 33 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +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/label/api.py b/InvenTree/label/api.py index 475d4f8fea..1a679d5ac5 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,46 @@ 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 + + """ + + 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 + ) + + 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 +173,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/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/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 1e6b7e38b7..451ddaf40f 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -393,6 +393,38 @@ 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 + + """ + + # 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..829aeaa935 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -95,7 +95,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 +190,25 @@ def after_delete(sender, instance, **kwargs): model=sender.__name__, table=table, ) + + +def print_label(plugin_slug, label_image, **kwargs): + """ + Print label with the provided plugin. + + This task is nominally handled by the background worker. + + 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 + + plugin.print_label(label_image) 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/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/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 5b416b4d22..46f7f32e42 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}`; } }, diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js index 1c843917e6..5215ce9d28 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, + }); } }); }