2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

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
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);
}