mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
e674ca7437
commit
a0b1ba62a9
@ -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
|
||||
|
@ -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 :-)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user