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
This commit is contained in:
Lukas 2023-11-01 14:39:19 +01:00 committed by GitHub
parent 59f17a9885
commit a11418398f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 303 additions and 180 deletions

View File

@ -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

View File

@ -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():

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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'

View File

@ -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.

View File

@ -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,

View File

@ -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 =`
<div class='form-group'>
<label class='control-label requiredField' for='id_plugin'>
{% trans "Select Printer" %}
</label>
<div class='controls'>
<select id='id_plugin' class='select form-control' name='plugin'>
`;
plugins.forEach(function(plugin) {
var selected = '';
if (user_settings['LABEL_DEFAULT_PRINTER'] == plugin.key) {
selected = ' selected';
}
plugin_selection += `<option value='${plugin.key}' title='${plugin.meta.human_name}'${selected}>${plugin.name} - <small>${plugin.meta.human_name}</small></option>`;
});
plugin_selection += `
</select>
</div>
</div>
`;
}
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 += `
<div class='alert alert-block alert-info'>
${items.length} ${item_name} {% trans "selected" %}
</div>`;
}
html += `
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
<div class='form-group'>
<label class='control-label requiredField' for='id_label'>
{% trans "Select Label Template" %}
</label>
<div class='controls'>
<select id='id_label' class='select form-control' name='label'>
${label_list}
</select>
</div>
</div>
${plugin_selection}
</form>`;
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 += `
<div class='alert alert-block alert-info'>
${options.items.length} ${options.plural_name} {% trans "selected" %}
</div>
`;
}
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: `<hr/><h5>{% trans "Printing Options" %}</h5>` },
...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} - <small>${t.description}</small>`,
})),
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} - <small>${p.meta.human_name}</small>`,
})),
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);
}

View File

@ -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']