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 =` -