From a11418398f3d5a137570a1282d2e7c79763e4f31 Mon Sep 17 00:00:00 2001 From: Lukas <76838159+wolflu05@users.noreply.github.com> Date: Wed, 1 Nov 2023 14:39:19 +0100 Subject: [PATCH] Printing options (#5786) * Added backend changes to support printing options * Pass printing options seperatly via kwargs for easier api refactor later * Implemented printing options in CUI * Fix js linting * Use translations for printing dialog * Added docs * Remove plugin and template fields from send printing options * Fix docs * Added tests * Fix tests * Fix options response and added test for it * Fix tests * Bump api version * Update docs * Apply suggestions from code review * Fix api change date --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/InvenTree/metadata.py | 4 + InvenTree/InvenTree/permissions.py | 4 + InvenTree/label/api.py | 52 +++- InvenTree/plugin/base/label/mixins.py | 31 +- .../plugin/base/label/test_label_mixin.py | 27 ++ .../samples/integration/label_sample.py | 6 + InvenTree/templates/js/translated/forms.js | 21 +- InvenTree/templates/js/translated/label.js | 290 ++++++++---------- docs/docs/extend/plugins/label.md | 42 ++- 10 files changed, 303 insertions(+), 180 deletions(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 04e1dbf7f9..92e5034cdb 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 144 +INVENTREE_API_VERSION = 145 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v145 -> 2023-10-30: https://github.com/inventree/InvenTree/pull/5786 + - Allow printing labels via POST including printing options in the body + v144 -> 2023-10-23: https://github.com/inventree/InvenTree/pull/5811 - Adds version information API endpoint @@ -106,7 +109,6 @@ v115 - > 2023-05-18 : https://github.com/inventree/InvenTree/pull/4846 v114 -> 2023-05-16 : https://github.com/inventree/InvenTree/pull/4825 - Adds "delivery_date" to shipments ->>>>>>> inventree/master v113 -> 2023-05-13 : https://github.com/inventree/InvenTree/pull/4800 - Adds API endpoints for scrapping a build output diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index f1e23f166c..2d4fb95024 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -84,6 +84,10 @@ class InvenTreeMetadata(SimpleMetadata): 'DELETE': 'delete', } + # let the view define a custom rolemap + if hasattr(view, "rolemap"): + rolemap.update(view.rolemap) + # Remove any HTTP methods that the user does not have permission for for method, permission in rolemap.items(): diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index 3029e4c35f..befbcab4c3 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -61,6 +61,10 @@ class RolePermission(permissions.BasePermission): 'DELETE': 'delete', } + # let the view define a custom rolemap + if hasattr(view, "rolemap"): + rolemap.update(view.rolemap) + permission = rolemap[request.method] # The required role may be defined for the view class diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index c4a722b4aa..53036b5774 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -7,7 +7,9 @@ from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page, never_cache from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import serializers from rest_framework.exceptions import NotFound +from rest_framework.request import clone_request import build.models import common.models @@ -136,17 +138,45 @@ class LabelListView(LabelFilterMixin, ListAPI): class LabelPrintMixin(LabelFilterMixin): """Mixin for printing labels.""" + rolemap = { + "GET": "view", + "POST": "view", + } + + def check_permissions(self, request): + """Override request method to GET so that also non superusers can print using a post request.""" + if request.method == "POST": + request = clone_request(request, "GET") + return super().check_permissions(request) + @method_decorator(never_cache) def dispatch(self, *args, **kwargs): """Prevent caching when printing report templates""" return super().dispatch(*args, **kwargs) + def get_serializer(self, *args, **kwargs): + """Define a get_serializer method to be discoverable by the OPTIONS request.""" + # Check the request to determine if the user has selected a label printing plugin + plugin = self.get_plugin(self.request) + + serializer = plugin.get_printing_options_serializer(self.request) + + # if no serializer is defined, return an empty serializer + if not serializer: + return serializers.Serializer() + + return serializer + def get(self, request, *args, **kwargs): """Perform a GET request against this endpoint to print labels""" common.models.InvenTreeUserSetting.set_setting('DEFAULT_' + self.ITEM_KEY.upper() + '_LABEL_TEMPLATE', self.get_object().pk, None, user=request.user) return self.print(request, self.get_items()) + def post(self, request, *args, **kwargs): + """Perform a GET request against this endpoint to print labels""" + return self.get(request, *args, **kwargs) + def get_plugin(self, request): """Return the label printing plugin associated with this request. @@ -167,14 +197,18 @@ class LabelPrintMixin(LabelFilterMixin): plugin = registry.get_plugin(plugin_key) - if plugin: - if plugin.is_active(): - # Only return the plugin if it is enabled! - return plugin - raise ValidationError(f"Plugin '{plugin_key}' is not enabled") - else: + if not plugin: raise NotFound(f"Plugin '{plugin_key}' not found") + if not plugin.is_active(): + raise ValidationError(f"Plugin '{plugin_key}' is not enabled") + + if not plugin.mixin_enabled("labels"): + raise ValidationError(f"Plugin '{plugin_key}' is not a label printing plugin") + + # Only return the plugin if it is enabled and has the label printing mixin + return plugin + 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 @@ -187,10 +221,14 @@ class LabelPrintMixin(LabelFilterMixin): # Label template label = self.get_object() + # if the plugin returns a serializer, validate the data + if serializer := plugin.get_printing_options_serializer(request, data=request.data): + serializer.is_valid(raise_exception=True) + # At this point, we offload the label(s) to the selected plugin. # The plugin is responsible for handling the request and returning a response. - result = plugin.print_labels(label, items_to_print, request) + result = plugin.print_labels(label, items_to_print, request, printing_options=request.data) if isinstance(result, JsonResponse): result['plugin'] = plugin.plugin_slug() diff --git a/InvenTree/plugin/base/label/mixins.py b/InvenTree/plugin/base/label/mixins.py index 80e77e7ebe..fe4aa1607f 100644 --- a/InvenTree/plugin/base/label/mixins.py +++ b/InvenTree/plugin/base/label/mixins.py @@ -1,8 +1,12 @@ """Plugin mixin classes for label plugins.""" +from typing import Union + from django.http import JsonResponse import pdf2image +from rest_framework import serializers +from rest_framework.request import Request from common.models import InvenTreeSetting from InvenTree.tasks import offload_task @@ -18,7 +22,7 @@ class LabelPrintingMixin: The plugin *must* also implement the print_label() function for rendering an individual label - Note that the print_labels() function can also be overridden to provide custom behaviour. + Note that the print_labels() function can also be overridden to provide custom behavior. """ # If True, the print_label() method will block until the label is printed @@ -70,7 +74,7 @@ class LabelPrintingMixin: png = pdf2image.convert_from_bytes(pdf_data, dpi=dpi)[0] return png - def print_labels(self, label: LabelTemplate, items: list, request, **kwargs): + def print_labels(self, label: LabelTemplate, items: list, request: Request, printing_options: dict, **kwargs): """Print one or more labels with the provided template and items. Arguments: @@ -78,6 +82,9 @@ class LabelPrintingMixin: items: The list of database items to print (e.g. StockItem instances) request: The HTTP request object which triggered this print job + Keyword arguments: + printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer + Returns: A JSONResponse object which indicates outcome to the user @@ -107,6 +114,7 @@ class LabelPrintingMixin: 'user': user, 'width': label.width, 'height': label.height, + 'printing_options': printing_options, } if self.BLOCKING_PRINT: @@ -136,6 +144,7 @@ class LabelPrintingMixin: user: The user who triggered this print job width: The expected width of the label (in mm) height: The expected height of the label (in mm) + printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer Note that the supplied kwargs may be different if the plugin overrides the print_labels() method. """ @@ -158,3 +167,21 @@ class LabelPrintingMixin: self.plugin_slug(), **kwargs ) + + def get_printing_options_serializer(self, request: Request, *args, **kwargs) -> Union[serializers.Serializer, None]: + """Return a serializer class instance with dynamic printing options. + + Arguments: + request: The request made to print a label or interfering the available serializer fields via an OPTIONS request + *args, **kwargs: need to be passed to the serializer instance + + Returns: + A class instance of a DRF serializer class, by default this an instance of + self.PrintingOptionsSerializer using the *args, **kwargs if existing for this plugin + """ + serializer = getattr(self, "PrintingOptionsSerializer", None) + + if not serializer: + return None + + return serializer(*args, **kwargs) diff --git a/InvenTree/plugin/base/label/test_label_mixin.py b/InvenTree/plugin/base/label/test_label_mixin.py index e99c09c8ff..9cb760660b 100644 --- a/InvenTree/plugin/base/label/test_label_mixin.py +++ b/InvenTree/plugin/base/label/test_label_mixin.py @@ -2,6 +2,7 @@ import json import os +from unittest import mock from django.apps import apps from django.urls import reverse @@ -183,6 +184,32 @@ class LabelMixinTests(InvenTreeAPITestCase): # And that it is a valid image file Image.open('label.png') + def test_printing_options(self): + """Test printing options.""" + # Ensure the labels were created + apps.get_app_config('label').create_labels() + + # Lookup references + plugin_ref = 'samplelabelprinter' + label = PartLabel.objects.first() + + self.do_activate_plugin() + + # test options response + options = self.options(self.do_url(Part.objects.all()[:2], plugin_ref, label), expected_code=200).json() + self.assertTrue("amount" in options["actions"]["POST"]) + + plg = registry.get_plugin(plugin_ref) + with mock.patch.object(plg, "print_label") as print_label: + # wrong value type + res = self.post(self.do_url(Part.objects.all()[:2], plugin_ref, label), data={"amount": "-no-valid-int-"}, expected_code=400).json() + self.assertTrue("amount" in res) + print_label.assert_not_called() + + # correct value type + self.post(self.do_url(Part.objects.all()[:2], plugin_ref, label), data={"amount": 13}, expected_code=200).json() + self.assertEqual(print_label.call_args.kwargs["printing_options"], {"amount": 13}) + def test_printing_endpoints(self): """Cover the endpoints not covered by `test_printing_process`.""" plugin_ref = 'samplelabelprinter' diff --git a/InvenTree/plugin/samples/integration/label_sample.py b/InvenTree/plugin/samples/integration/label_sample.py index f0b5629cc5..e35f6ee6f1 100644 --- a/InvenTree/plugin/samples/integration/label_sample.py +++ b/InvenTree/plugin/samples/integration/label_sample.py @@ -3,6 +3,8 @@ This does not function in real usage and is more to show the required components and for unit tests. """ +from rest_framework import serializers + from plugin import InvenTreePlugin from plugin.mixins import LabelPrintingMixin @@ -17,6 +19,10 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin): AUTHOR = "InvenTree contributors" VERSION = "0.3.0" + class PrintingOptionsSerializer(serializers.Serializer): + """Serializer to return printing options.""" + amount = serializers.IntegerField(required=False, default=1) + def print_label(self, **kwargs): """Sample printing step. diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index bfa2ef73e8..69ab8cbd89 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -32,6 +32,7 @@ setFormGroupVisibility, showFormInput, selectImportFields, + updateForm, */ /** @@ -306,6 +307,7 @@ function constructDeleteForm(fields, options) { * - hidden: Set to true to hide the field * - icon: font-awesome icon to display before the field * - prefix: Custom HTML prefix to display before the field + * - localOnly: If true, this field will only be rendered, but not send to the server * - data: map of data to fill out field values with * - focus: Name of field to focus on when modal is displayed * - preventClose: Set to true to prevent form from closing on success @@ -315,6 +317,7 @@ function constructDeleteForm(fields, options) { * - reload: Set to true to reload the current page after form success * - confirm: Set to true to require a "confirm" button * - confirmText: Text for confirm button (default = "Confirm") + * - disableSuccessMessage: Set to true to suppress the success message if the response contains a success key by accident * */ function constructForm(url, options={}) { @@ -720,6 +723,21 @@ function constructFormBody(fields, options) { }); } +/** + * This Method updates an existing form by replacing all form fields with the new ones + * @param {*} options new form definition options + */ +function updateForm(options) { + // merge already entered values in the newly constructed form + options.data = extractFormData(options.fields, options); + + // remove old submit handlers + $(options.modal).off('click', '#modal-form-submit'); + + // construct new form + constructFormBody(options.fields, options); +} + // Add a "confirm" checkbox to the modal // The "submit" button will be disabled unless "confirm" is checked @@ -841,6 +859,7 @@ function submitFormData(fields, options) { // Ignore visual fields if (field && field.type == 'candy') continue; + if (field && field.localOnly === true) continue; if (field) { @@ -1190,7 +1209,7 @@ function handleFormSuccess(response, options) { } // Display any messages - if (response && (response.success || options.successMessage)) { + if (!options.disableSuccessMessage && response && (response.success || options.successMessage)) { showAlertOrCache( response.success || options.successMessage, cache, diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js index 54b3e48fc8..57ee46e919 100644 --- a/InvenTree/templates/js/translated/label.js +++ b/InvenTree/templates/js/translated/label.js @@ -4,6 +4,8 @@ /* globals attachSelect, closeModal, + constructForm, + getFormFieldValue, inventreeGet, makeOptionsList, modalEnable, @@ -13,7 +15,9 @@ modalSubmit, openModal, showAlertDialog, + showApiError, showMessage, + updateForm, user_settings, */ @@ -21,137 +25,11 @@ printLabels, */ -/** - * Present the user with the available labels, - * and allow them to select which label to print. - * - * The intent is that the available labels have been requested - * (via AJAX) from the server. - */ -function selectLabel(labels, items, options={}) { - // Array of available plugins for label printing - var plugins = []; - - // Request a list of available label printing plugins from the server - inventreeGet( - `/api/plugins/`, - { - mixin: 'labels', - }, - { - async: false, - success: function(response) { - plugins = response; - } - } - ); - - var plugin_selection = ''; - - if (plugins.length > 0) { - plugin_selection =` -
- -
- -
-
- `; - } - - var modal = options.modal || '#modal-form'; - var label_list = makeOptionsList( - labels, - function(item) { - var text = item.name; - - if (item.description) { - text += ` - ${item.description}`; - } - - return text; - }, - function(item) { - return item.pk; - }, - null, - function(item) { - if (options.key == 'part') - return item.pk == user_settings.DEFAULT_PART_LABEL_TEMPLATE; - else if (options.key == 'location') - return item.pk == user_settings.DEFAULT_LOCATION_LABEL_TEMPLATE; - else if (options.key == 'item') - return item.pk == user_settings.DEFAULT_ITEM_LABEL_TEMPLATE; - return ''; - } - ); - - // Construct form - var html = ''; - - if (items.length > 0) { - let item_name = items.length == 1 ? options.singular_name : options.plural_name; - html += ` -
- ${items.length} ${item_name} {% trans "selected" %} -
`; - } - - html += ` -
-
- -
- -
-
- ${plugin_selection} -
`; - - openModal({ - modal: modal, - }); - - modalEnable(modal, true); - modalShowSubmitButton(modal, true); - modalSetTitle(modal, '{% trans "Select Label Template" %}'); - modalSetContent(modal, html); - - attachSelect(modal); - - modalSubmit(modal, function() { - - var label = $(modal).find('#id_label').val(); - var plugin = $(modal).find('#id_plugin').val(); - - closeModal(modal); - - if (options.success) { - options.success({ - // Return the selected label template and plugin - label: label, - plugin: plugin, - }); - } - }); +const defaultLabelTemplates = { + part: user_settings.DEFAULT_PART_LABEL_TEMPLATE, + location: user_settings.DEFAULT_LOCATION_LABEL_TEMPLATE, + item: user_settings.DEFAULT_ITEM_LABEL_TEMPLATE, + line: user_settings.DEFAULT_LINE_LABEL_TEMPLATE, } @@ -166,6 +44,7 @@ function selectLabel(labels, items, options={}) { * - url: The list URL for the particular template type * - items: The list of items to be printed * - key: The key to use in the query parameters + * - plural_name: The plural name of the item type */ function printLabels(options) { @@ -183,9 +62,11 @@ function printLabels(options) { params[options.key] = options.items; - // Request a list of available label templates + // Request a list of available label templates from the server + let labelTemplates = []; inventreeGet(options.url, params, { - success: function(response) { + async: false, + success: function (response) { if (response.length == 0) { showAlertDialog( '{% trans "No Labels Found" %}', @@ -194,34 +75,121 @@ function printLabels(options) { return; } - // Select label template for printing - selectLabel(response, options.items, { - success: function(data) { - let href = `${options.url}${data.label}/print/?`; - - options.items.forEach(function(item) { - href += `${options.key}=${item}&`; - }); - - href += `plugin=${data.plugin}`; - - inventreeGet(href, {}, { - success: function(response) { - if (response.file) { - // Download the generated file - window.open(response.file); - } else { - showMessage('{% trans "Labels sent to printer" %}', { - style: 'success', - }); - } - } - }); - }, - plural_name: options.plural_name, - singular_name: options.singular_name, - key: options.key, - }); + labelTemplates = response; } }); + + // Request a list of available label printing plugins from the server + let plugins = []; + inventreeGet(`/api/plugins/`, { mixin: 'labels' }, { + async: false, + success: function (response) { + plugins = response; + } + }); + + let header_html = ""; + + // show how much items are selected if there is more than one item selected + if (options.items.length > 1) { + header_html += ` +
+ ${options.items.length} ${options.plural_name} {% trans "selected" %} +
+ `; + } + + const updateFormUrl = (formOptions) => { + const plugin = getFormFieldValue("_plugin", formOptions.fields._plugin, formOptions); + const labelTemplate = getFormFieldValue("_label_template", formOptions.fields._label_template, formOptions); + const params = $.param({ plugin, [options.key]: options.items }) + formOptions.url = `${options.url}${labelTemplate ?? "1"}/print/?${params}`; + } + + const updatePrintingOptions = (formOptions) => { + let printingOptionsRes = null; + $.ajax({ + url: formOptions.url, + type: "OPTIONS", + contentType: "application/json", + dataType: "json", + accepts: { json: "application/json" }, + async: false, + success: (res) => { printingOptionsRes = res }, + error: (xhr) => showApiError(xhr, formOptions.url) + }); + + const printingOptions = printingOptionsRes.actions.POST || {}; + + // clear all other options + formOptions.fields = { + _label_template: formOptions.fields._label_template, + _plugin: formOptions.fields._plugin, + } + + if (Object.keys(printingOptions).length > 0) { + formOptions.fields = { + ...formOptions.fields, + divider: { type: "candy", html: `
{% trans "Printing Options" %}
` }, + ...printingOptions, + }; + } + + // update form + updateForm(formOptions); + } + + const printingFormOptions = { + title: options.items.length === 1 ? `{% trans "Print label" %}` : `{% trans "Print labels" %}`, + submitText: `{% trans "Print" %}`, + method: "POST", + disableSuccessMessage: true, + header_html, + fields: { + _label_template: { + label: `{% trans "Select label template" %}`, + type: "choice", + localOnly: true, + value: defaultLabelTemplates[options.key], + choices: labelTemplates.map(t => ({ + value: t.pk, + display_name: `${t.name} - ${t.description}`, + })), + onEdit: (_value, _name, _field, formOptions) => { + updateFormUrl(formOptions); + } + }, + _plugin: { + label: `{% trans "Select plugin" %}`, + type: "choice", + localOnly: true, + value: user_settings.LABEL_DEFAULT_PRINTER || plugins[0].key, + choices: plugins.map(p => ({ + value: p.key, + display_name: `${p.name} - ${p.meta.human_name}`, + })), + onEdit: (_value, _name, _field, formOptions) => { + updateFormUrl(formOptions); + updatePrintingOptions(formOptions); + } + }, + }, + onSuccess: (response) => { + if (response.file) { + // Download the generated file + window.open(response.file); + } else { + showMessage('{% trans "Labels sent to printer" %}', { + style: 'success', + }); + } + } + }; + + // construct form + constructForm(null, printingFormOptions); + + // fetch the options for the default plugin + updateFormUrl(printingFormOptions); + updatePrintingOptions(printingFormOptions); } diff --git a/docs/docs/extend/plugins/label.md b/docs/docs/extend/plugins/label.md index 165a497e7e..72d6579dab 100644 --- a/docs/docs/extend/plugins/label.md +++ b/docs/docs/extend/plugins/label.md @@ -63,6 +63,32 @@ If the `print_labels` method is not changed, this will run the `print_label` met !!! tip "Custom Code" If your plugin overrides the `print_labels` method, you will have to ensure that the label printing is correctly offloaded to the background worker. Look at the `offload_label` method of the plugin mixin class for how this can be achieved. +### Printing options + +A printing plugin can define custom options as a serializer class called `PrintingOptionsSerializer` that get shown on the printing screen and get passed to the `print_labels`/`print_label` function as a kwarg called `printing_options`. This can be used to e.g. let the user dynamically select the orientation of the label, the color mode, ... for each print job. +The following simple example shows how to implement an orientation select. For more information about how to define fields, refer to the django rest framework (DRF) [documentation](https://www.django-rest-framework.org/api-guide/fields/). + +```py +from rest_framework import serializers + +class MyLabelPrinter(LabelPrintingMixin, InvenTreePlugin): + ... + + class PrintingOptionsSerializer(serializers.Serializer): + orientation = serializers.ChoiceField(choices=[ + ("landscape", "Landscape"), + ("portrait", "Portrait"), + ]) + + def print_label(self, **kwargs): + print(kwargs["printing_options"]) # -> {"orientation": "landscape"} + ... +``` + +!!! tip "Dynamically return a serializer instance" + If your plugin wants to dynamically expose options based on the request, you can implement the `get_printing_options_serializer` function which by default returns an instance + of the `PrintingOptionsSerializer` class if defined. + ### Helper Methods The plugin class provides a number of additional helper methods which may be useful for generating labels: @@ -122,13 +148,15 @@ class MyLabelPrinter(LabelPrintingMixin, InvenTreePlugin): Send the label to the printer kwargs: - pdf_data: An in-memory PDF file of the label - png_file: An in-memory PIL (pillow) Image file of the label - filename: The filename of the printed label (if applicable) - label_instance: The Label model instance - width: width of the label (in mm) - height: height of the label (in mm) - user: The user who printed this label + pdf_file: The PDF file object of the rendered label (WeasyTemplateResponse object) + pdf_data: Raw PDF data of the rendered label + filename: The filename of this PDF label + label_instance: The instance of the label model which triggered the print_label() method + item_instance: The instance of the database model against which the label is printed + user: The user who triggered this print job + width: The expected width of the label (in mm) + height: The expected height of the label (in mm) + printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer """ width = kwargs['width']