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 %} - {% 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 = """" + + # 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 += " | " + + html += "