From a0b1ba62a91cd6322ee419cdf8810b6a3bbe36d5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 9 Nov 2023 09:00:23 +1100 Subject: [PATCH] Label sheet printer (#5883) * Add skeleton for new label sheet plugin * Add custom printing options serializer * Render individual label outputs to HTML * Extract page size and column size * Check label dimensions before printing * Split labels into multiple pages / sheets * Render out multiple labels onto a single sheet * Cleanup base label template - Allow @page style to *not* be generated - Pass through as optional context variable - Check that it still works for single label printing (default behaviour unchanged) - Prevents multiple @page styles from being generated on label sheet output * Fix stylesheets for part labels * Cleanup stock location labels * Cleanup more label templates * Check if label can actually fit on page * Generate output to PDF and return correct response * Update panel.md * Fix unit tests * More unit test fixes --- InvenTree/label/api.py | 4 + InvenTree/label/models.py | 59 +++- .../label/buildline/buildline_label_base.html | 2 +- .../label/templates/label/label_base.html | 30 +- .../templates/label/part/part_label.html | 4 +- .../label/part/part_label_code128.html | 4 +- .../label/templates/label/stockitem/qr.html | 2 +- .../templates/label/stocklocation/qr.html | 3 +- .../label/stocklocation/qr_and_text.html | 4 +- .../plugin/base/label/test_label_mixin.py | 13 +- .../plugin/builtin/labels/inventree_label.py | 1 + .../plugin/builtin/labels/label_sheet.py | 267 ++++++++++++++++++ InvenTree/report/helpers.py | 20 ++ docs/docs/extend/plugins/panel.md | 2 +- 14 files changed, 380 insertions(+), 35 deletions(-) create mode 100644 InvenTree/plugin/builtin/labels/label_sheet.py diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 53036b5774..0371b2220f 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -221,6 +221,10 @@ class LabelPrintMixin(LabelFilterMixin): # Label template label = self.get_object() + # Check the label dimensions + if label.width <= 0 or label.height <= 0: + raise ValidationError('Label has invalid dimensions') + # 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) diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index 3b33f86e9a..65a0306ce1 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -191,10 +191,38 @@ class LabelTemplate(MetadataMixin, models.Model): return template_string.render(context) - def context(self, request): - """Provides context data to the template.""" + def generate_page_style(self, **kwargs): + """Generate @page style for the label template. + + This is inserted at the top of the style block for a given label + """ + + width = kwargs.get('width', self.width) + height = kwargs.get('height', self.height) + margin = kwargs.get('margin', 0) + + return f""" + @page {{ + size: {width}mm {height}mm; + margin: {margin}mm; + }} + """ + + def context(self, request, **kwargs): + """Provides context data to the template. + + Arguments: + request: The HTTP request object + kwargs: Additional keyword arguments + """ + context = self.get_context_data(request) + # By default, each label is supplied with '@page' data + # However, it can be excluded, e.g. when rendering a label sheet + if kwargs.get('insert_page_style', True): + context['page_style'] = self.generate_page_style() + # Add "basic" context data which gets passed to every label context['base_url'] = get_base_url(request=request) context['date'] = datetime.datetime.now().date() @@ -213,18 +241,31 @@ class LabelTemplate(MetadataMixin, models.Model): return context - def render_as_string(self, request, **kwargs): - """Render the label to a HTML string. + def render_as_string(self, request, target_object=None, **kwargs): + """Render the label to a HTML string""" - Useful for debug mode (viewing generated code) - """ - return render_to_string(self.template_name, self.context(request), request) + if target_object: + self.object_to_print = target_object - def render(self, request, **kwargs): + context = self.context(request, **kwargs) + + return render_to_string( + self.template_name, + context, + request + ) + + def render(self, request, target_object=None, **kwargs): """Render the label template to a PDF file. Uses django-weasyprint plugin to render HTML template """ + + if target_object: + self.object_to_print = target_object + + context = self.context(request, **kwargs) + wp = WeasyprintLabelMixin( request, self.template_name, @@ -235,7 +276,7 @@ class LabelTemplate(MetadataMixin, models.Model): ) return wp.render_to_response( - self.context(request), + context, **kwargs ) diff --git a/InvenTree/label/templates/label/buildline/buildline_label_base.html b/InvenTree/label/templates/label/buildline/buildline_label_base.html index bec22c5cbb..a088a456f7 100644 --- a/InvenTree/label/templates/label/buildline/buildline_label_base.html +++ b/InvenTree/label/templates/label/buildline/buildline_label_base.html @@ -16,9 +16,9 @@ Refer to the documentation for a full list of available template variables. } .qr { + position: absolute; height: 28mm; width: 28mm; - position: relative; top: 0mm; right: 0mm; float: right; diff --git a/InvenTree/label/templates/label/label_base.html b/InvenTree/label/templates/label/label_base.html index 6ee1c42575..327bf5f60a 100644 --- a/InvenTree/label/templates/label/label_base.html +++ b/InvenTree/label/templates/label/label_base.html @@ -4,15 +4,18 @@ diff --git a/InvenTree/label/templates/label/part/part_label.html b/InvenTree/label/templates/label/part/part_label.html index 45f1d569f5..d1cae87d8f 100644 --- a/InvenTree/label/templates/label/part/part_label.html +++ b/InvenTree/label/templates/label/part/part_label.html @@ -5,7 +5,7 @@ {% block style %} .qr { - position: fixed; + position: absolute; left: 0mm; top: 0mm; {% localize off %} @@ -16,7 +16,7 @@ .part { font-family: Arial, Helvetica, sans-serif; - display: inline; + display: flex; position: absolute; {% localize off %} left: {{ height }}mm; diff --git a/InvenTree/label/templates/label/part/part_label_code128.html b/InvenTree/label/templates/label/part/part_label_code128.html index 982baf4422..37b4abd4a9 100644 --- a/InvenTree/label/templates/label/part/part_label_code128.html +++ b/InvenTree/label/templates/label/part/part_label_code128.html @@ -5,7 +5,7 @@ {% block style %} .qr { - position: fixed; + position: absolute; left: 0mm; top: 0mm; {% localize off %} @@ -16,7 +16,7 @@ .part { font-family: Arial, Helvetica, sans-serif; - display: inline; + display: flex; position: absolute; {% localize off %} left: {{ height }}mm; diff --git a/InvenTree/label/templates/label/stockitem/qr.html b/InvenTree/label/templates/label/stockitem/qr.html index 2208dd53c3..b588834a15 100644 --- a/InvenTree/label/templates/label/stockitem/qr.html +++ b/InvenTree/label/templates/label/stockitem/qr.html @@ -5,7 +5,7 @@ {% block style %} .qr { - position: fixed; + position: absolute; left: 0mm; top: 0mm; {% localize off %} diff --git a/InvenTree/label/templates/label/stocklocation/qr.html b/InvenTree/label/templates/label/stocklocation/qr.html index 2208dd53c3..fe3d1b519e 100644 --- a/InvenTree/label/templates/label/stocklocation/qr.html +++ b/InvenTree/label/templates/label/stocklocation/qr.html @@ -5,7 +5,7 @@ {% block style %} .qr { - position: fixed; + position: absolute; left: 0mm; top: 0mm; {% localize off %} @@ -17,6 +17,5 @@ {% endblock style %} {% block content %} - {% trans 'QR Code' %} {% endblock content %} diff --git a/InvenTree/label/templates/label/stocklocation/qr_and_text.html b/InvenTree/label/templates/label/stocklocation/qr_and_text.html index aa18b3d332..b5b1ccb3ff 100644 --- a/InvenTree/label/templates/label/stocklocation/qr_and_text.html +++ b/InvenTree/label/templates/label/stocklocation/qr_and_text.html @@ -5,7 +5,7 @@ {% block style %} .qr { - position: fixed; + position: absolute; left: 0mm; top: 0mm; {% localize off %} @@ -16,7 +16,7 @@ .loc { font-family: Arial, Helvetica, sans-serif; - display: inline; + display: flex; position: absolute; {% localize off %} left: {{ height }}mm; diff --git a/InvenTree/plugin/base/label/test_label_mixin.py b/InvenTree/plugin/base/label/test_label_mixin.py index 9cb760660b..6824ca55f7 100644 --- a/InvenTree/plugin/base/label/test_label_mixin.py +++ b/InvenTree/plugin/base/label/test_label_mixin.py @@ -78,11 +78,11 @@ class LabelMixinTests(InvenTreeAPITestCase): """Test that the sample printing plugin is installed.""" # Get all label plugins plugins = registry.with_mixin('labels') - self.assertEqual(len(plugins), 2) + self.assertEqual(len(plugins), 3) # But, it is not 'active' plugins = registry.with_mixin('labels', active=True) - self.assertEqual(len(plugins), 1) + self.assertEqual(len(plugins), 2) def test_api(self): """Test that we can filter the API endpoint by mixin.""" @@ -124,9 +124,12 @@ class LabelMixinTests(InvenTreeAPITestCase): } ) - self.assertEqual(len(response.data), 2) - data = response.data[1] - self.assertEqual(data['key'], 'samplelabelprinter') + self.assertEqual(len(response.data), 3) + + labels = [item['key'] for item in response.data] + + self.assertIn('samplelabelprinter', labels) + self.assertIn('inventreelabelsheet', labels) def test_printing_process(self): """Test that a label can be printed.""" diff --git a/InvenTree/plugin/builtin/labels/inventree_label.py b/InvenTree/plugin/builtin/labels/inventree_label.py index a48f5bd04c..8fa02c9042 100644 --- a/InvenTree/plugin/builtin/labels/inventree_label.py +++ b/InvenTree/plugin/builtin/labels/inventree_label.py @@ -90,4 +90,5 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): if debug: return self.render_to_html(label, request, **kwargs) + return self.render_to_pdf(label, request, **kwargs) diff --git a/InvenTree/plugin/builtin/labels/label_sheet.py b/InvenTree/plugin/builtin/labels/label_sheet.py new file mode 100644 index 0000000000..e70b08f450 --- /dev/null +++ b/InvenTree/plugin/builtin/labels/label_sheet.py @@ -0,0 +1,267 @@ +"""Label printing plugin which supports printing multiple labels on a single page""" + +import logging +import math + +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.http import JsonResponse +from django.utils.translation import gettext_lazy as _ + +import weasyprint +from rest_framework import serializers + +import report.helpers +from label.models import LabelOutput, LabelTemplate +from plugin import InvenTreePlugin +from plugin.mixins import LabelPrintingMixin, SettingsMixin + +logger = logging.getLogger('inventree') + + +class LabelPrintingOptionsSerializer(serializers.Serializer): + """Custom printing options for the label sheet plugin""" + + page_size = serializers.ChoiceField( + choices=report.helpers.report_page_size_options(), + default='A4', + label=_('Page Size'), + help_text=_('Page size for the label sheet') + ) + + border = serializers.BooleanField( + default=False, + label=_('Border'), + help_text=_('Print a border around each label') + ) + + landscape = serializers.BooleanField( + default=False, + label=_('Landscape'), + help_text=_('Print the label sheet in landscape mode') + ) + + +class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin): + """Builtin plugin for label printing. + + This plugin arrays multiple labels onto a single larger sheet, + and returns the resulting PDF file. + """ + + NAME = "InvenTreeLabelSheet" + TITLE = _("InvenTree Label Sheet Printer") + DESCRIPTION = _("Arrays multiple labels onto a single sheet") + VERSION = "1.0.0" + AUTHOR = _("InvenTree contributors") + + BLOCKING_PRINT = True + + SETTINGS = {} + + PrintingOptionsSerializer = LabelPrintingOptionsSerializer + + def print_labels(self, label: LabelTemplate, items: list, request, **kwargs): + """Handle printing of the provided labels""" + + printing_options = kwargs['printing_options'] + + # Extract page size for the label sheet + page_size_code = printing_options.get('page_size', 'A4') + landscape = printing_options.get('landscape', False) + border = printing_options.get('border', False) + + # Extract size of page + page_size = report.helpers.page_size(page_size_code) + page_width, page_height = page_size + + if landscape: + page_width, page_height = page_height, page_width + + # Calculate number of rows and columns + n_cols = math.floor(page_width / label.width) + n_rows = math.floor(page_height / label.height) + n_cells = n_cols * n_rows + + if n_cells == 0: + raise ValidationError(_("Label is too large for page size")) + + n_labels = len(items) + + # Data to pass through to each page + document_data = { + "border": border, + "landscape": landscape, + "page_width": page_width, + "page_height": page_height, + "label_width": label.width, + "label_height": label.height, + "n_labels": n_labels, + "n_pages": math.ceil(n_labels / n_cells), + "n_cols": n_cols, + "n_rows": n_rows, + } + + pages = [] + + idx = 0 + + while idx < n_labels: + if page := self.print_page(label, items[idx:idx + n_cells], request, **document_data): + pages.append(page) + + idx += n_cells + + if len(pages) == 0: + raise ValidationError(_("No labels were generated")) + + # Render to a single HTML document + html_data = self.wrap_pages(pages, **document_data) + + # Render HTML to PDF + html = weasyprint.HTML(string=html_data) + document = html.render().write_pdf() + + output_file = ContentFile(document, 'labels.pdf') + + output = LabelOutput.objects.create( + label=output_file, + user=request.user + ) + + return JsonResponse({ + 'file': output.label.url, + 'success': True, + 'message': f'{len(items)} labels generated' + }) + + def print_page(self, label: LabelTemplate, items: list, request, **kwargs): + """Generate a single page of labels: + + For a single page, generate a simple table grid of labels. + Styling of the table is handled by the higher level label template + + Arguments: + label: The LabelTemplate object to use for printing + items: The list of database items to print (e.g. StockItem instances) + request: The HTTP request object which triggered this print job + + Kwargs: + n_cols: Number of columns + n_rows: Number of rows + """ + + n_cols = kwargs['n_cols'] + n_rows = kwargs['n_rows'] + + # Generate a table of labels + html = """""" + + for row in range(n_rows): + html += "" + + for col in range(n_cols): + html += f"" + + html += "" + + html += "
" + + # Cell index + idx = row * n_cols + col + + if idx < len(items): + try: + # Render the individual label template + # Note that we disable @page styling for this + cell = label.render_as_string( + request, + target_object=items[idx], + insert_page_style=False + ) + html += cell + except Exception as exc: + logger.exception("Error rendering label: %s", str(exc)) + html += """ +
+ """ + + html += "
" + + return html + + def wrap_pages(self, pages, **kwargs): + """Wrap the generated pages into a single document""" + + border = kwargs['border'] + + page_width = kwargs['page_width'] + page_height = kwargs['page_height'] + + label_width = kwargs['label_width'] + label_height = kwargs['label_height'] + + n_rows = kwargs['n_rows'] + n_cols = kwargs['n_cols'] + + inner = ''.join(pages) + + # Generate styles for individual cells (on each page) + cell_styles = [] + + for row in range(n_rows): + cell_styles.append(f""" + .label-sheet-row-{row} {{ + top: {row * label_height}mm; + }} + """) + + for col in range(n_cols): + cell_styles.append(f""" + .label-sheet-col-{col} {{ + left: {col * label_width}mm; + }} + """) + + cell_styles = "\n".join(cell_styles) + + return f""" + + + + + {inner} + + + """ diff --git a/InvenTree/report/helpers.py b/InvenTree/report/helpers.py index 9e30b7a259..e399dcb908 100644 --- a/InvenTree/report/helpers.py +++ b/InvenTree/report/helpers.py @@ -17,6 +17,26 @@ def report_page_size_options(): ] +def page_sizes(): + """Returns a dict of page sizes for PDF reports.""" + return { + 'A4': (210, 297), + 'A3': (297, 420), + 'Legal': (215.9, 355.6), + 'Letter': (215.9, 279.4), + } + + +def page_size(page_code): + """Return the page size associated with a particular page code""" + if page_code in page_sizes(): + return page_sizes()[page_code] + + # Default to A4 + logger.warning("Unknown page size code '%s' - defaulting to A4", page_code) + return page_sizes()['A4'] + + def report_page_size_default(): """Returns the default page size for PDF reports.""" from common.models import InvenTreeSetting diff --git a/docs/docs/extend/plugins/panel.md b/docs/docs/extend/plugins/panel.md index babb6bc848..2eb9c60f1a 100644 --- a/docs/docs/extend/plugins/panel.md +++ b/docs/docs/extend/plugins/panel.md @@ -12,6 +12,7 @@ Most pages in the web interface support multiple panels, which are selected via {% include 'img.html' %} {% endwith %} + Each plugin which implements this mixin can return zero or more custom panels for a particular page. The plugin can decide (at runtime) which panels it wishes to render. This determination can be made based on the page routing, the item being viewed, the particular user, or other considerations. ### Panel Content @@ -311,4 +312,3 @@ import json The URL and the called function have no parameter names any longer. All data is in the request message and can be extracted from this using json.loads. If more data is needed just add it to the json container. No further changes are needed. It's really simple :-) -