mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 20:45:44 +00:00
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
This commit is contained in:
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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;
|
||||
|
@ -4,15 +4,18 @@
|
||||
|
||||
<head>
|
||||
<style>
|
||||
@page {
|
||||
{% localize off %}
|
||||
size: {{ width }}mm {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
{% block margin %}
|
||||
margin: 0mm;
|
||||
{% endblock margin %}
|
||||
}
|
||||
|
||||
{% block page_style %}
|
||||
{% if page_style %}
|
||||
/* @page styling */
|
||||
{% localize off %}
|
||||
{{ page_style }}
|
||||
{% endlocalize %}
|
||||
{% endif %}
|
||||
{% endblock page_style %}
|
||||
|
||||
{% block body_style %}
|
||||
/* body styling */
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
margin: 0mm;
|
||||
@ -21,20 +24,27 @@
|
||||
page-break-before: always;
|
||||
page-break-after: always;
|
||||
}
|
||||
{% endblock body_style %}
|
||||
|
||||
img {
|
||||
display: inline-block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Global content wrapper div which takes up entire page area */
|
||||
.content {
|
||||
width: 100%;
|
||||
{% localize off %}
|
||||
width: {{ width }}mm;
|
||||
height: {{ height }}mm;
|
||||
{% endlocalize %}
|
||||
break-after: always;
|
||||
position: relative;
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
}
|
||||
|
||||
{% block style %}
|
||||
/* User-defined styles can go here */
|
||||
/* User-defined styles can go here, and override any styles defined above */
|
||||
{% endblock style %}
|
||||
|
||||
</style>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -5,7 +5,7 @@
|
||||
{% block style %}
|
||||
|
||||
.qr {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
left: 0mm;
|
||||
top: 0mm;
|
||||
{% localize off %}
|
||||
|
@ -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 %}
|
||||
|
||||
<img class='qr' alt="{% trans 'QR Code' %}" src='{% qrcode qr_data %}'>
|
||||
{% endblock content %}
|
||||
|
@ -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;
|
||||
|
@ -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."""
|
||||
|
@ -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)
|
||||
|
267
InvenTree/plugin/builtin/labels/label_sheet.py
Normal file
267
InvenTree/plugin/builtin/labels/label_sheet.py
Normal file
@ -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 = """<table class='label-sheet-table'>"""
|
||||
|
||||
for row in range(n_rows):
|
||||
html += "<tr class='label-sheet-row'>"
|
||||
|
||||
for col in range(n_cols):
|
||||
html += f"<td class='label-sheet-cell label-sheet-row-{row} label-sheet-col-{col}'>"
|
||||
|
||||
# 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 += """
|
||||
<div class='label-sheet-cell-error'></div>
|
||||
"""
|
||||
|
||||
html += "</td>"
|
||||
|
||||
html += "</tr>"
|
||||
|
||||
html += "</table>"
|
||||
|
||||
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"""
|
||||
<head>
|
||||
<style>
|
||||
@page {{
|
||||
size: {page_width}mm {page_height}mm;
|
||||
margin: 0mm;
|
||||
padding: 0mm;
|
||||
}}
|
||||
|
||||
.label-sheet-table {{
|
||||
page-break-after: always;
|
||||
table-layout: fixed;
|
||||
width: {page_width}mm;
|
||||
border-spacing: 0mm 0mm;
|
||||
}}
|
||||
|
||||
.label-sheet-cell-error {{
|
||||
background-color: #F00;
|
||||
}}
|
||||
|
||||
.label-sheet-cell {{
|
||||
border: {"1px solid #000;" if border else "0mm;"}
|
||||
width: {label_width}mm;
|
||||
height: {label_height}mm;
|
||||
padding: 0mm;
|
||||
position: absolute;
|
||||
}}
|
||||
|
||||
{cell_styles}
|
||||
|
||||
body {{
|
||||
margin: 0mm !important;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{inner}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
@ -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
|
||||
|
Reference in New Issue
Block a user