mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Label plugin refactor (#5251)
* Add skeleton for builtin label printing plugin * Force selection of plugin when printing labels * Enhance LabelPrintingMixin class - Add render_to_pdf method - Add render_to_html method * Enhance plugin mixin - Add class attribute to select blocking or non-blocking printing - Add render_to_png method - Add default method for printing multiple labels - Add method for offloding print job * Simplify print_label background function - All arguments now handled by specific plugin * Simplify label printing API - Simply pass data to the particular plugin - Check result type - Return result * Updated sample plugin * Working on client side code * Cleanup * Update sample plugin * Add new model type - LabelOutput model - Stores generated label file to the database - Makes available for download * Update label printing plugin mixin * Add background task to remove any old label outputs * Open file if response contains filename * Remove "default printer" option which does not specify a plugin * Delete old labels after 5 days * Remove debug statements * Update API version * Changed default behaviour to background printing * Update label plugin mixin docs * Provide default printer if none provided (legacy) * Update unit test * unit test updates * Further fixes for unit tests * unit test updates
This commit is contained in:
parent
4d7fb751eb
commit
e8d16298a4
8
.gitignore
vendored
8
.gitignore
vendored
@ -40,9 +40,11 @@ inventree-demo-dataset/
|
||||
inventree-data/
|
||||
dummy_image.*
|
||||
_tmp.csv
|
||||
inventree/label.pdf
|
||||
inventree/label.png
|
||||
inventree/my_special*
|
||||
InvenTree/label.pdf
|
||||
InvenTree/label.png
|
||||
label.pdf
|
||||
label.png
|
||||
InvenTree/my_special*
|
||||
_tests*.txt
|
||||
|
||||
# Local static and media file storage (only when running in development mode)
|
||||
|
@ -2,11 +2,14 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 129
|
||||
INVENTREE_API_VERSION = 130
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v130 -> 2023-07-14 : https://github.com/inventree/InvenTree/pull/5251
|
||||
- Refactor label printing interface
|
||||
|
||||
v129 -> 2023-07-06 : https://github.com/inventree/InvenTree/pull/5189
|
||||
- Changes 'serial_lte' and 'serial_gte' stock filters to point to 'serial_int' field
|
||||
|
||||
|
@ -1794,7 +1794,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
|
||||
def label_printer_options():
|
||||
"""Build a list of available label printer options."""
|
||||
printers = [('', _('No Printer (Export to PDF)'))]
|
||||
printers = []
|
||||
label_printer_plugins = registry.with_mixin('labels')
|
||||
if label_printer_plugins:
|
||||
printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins])
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.http import JsonResponse
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page, never_cache
|
||||
@ -18,9 +18,8 @@ import label.serializers
|
||||
from InvenTree.api import MetadataView
|
||||
from InvenTree.filters import InvenTreeSearchFilter
|
||||
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
|
||||
from InvenTree.tasks import offload_task
|
||||
from part.models import Part
|
||||
from plugin.base.label import label as plugin_label
|
||||
from plugin.builtin.labels.inventree_label import InvenTreeLabelPlugin
|
||||
from plugin.registry import registry
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
@ -167,9 +166,10 @@ class LabelPrintMixin(LabelFilterMixin):
|
||||
|
||||
plugin_key = request.query_params.get('plugin', None)
|
||||
|
||||
# No plugin provided, and that's OK
|
||||
# No plugin provided!
|
||||
if plugin_key is None:
|
||||
return None
|
||||
# Default to the builtin label printing plugin
|
||||
plugin_key = InvenTreeLabelPlugin.NAME.lower()
|
||||
|
||||
plugin = registry.get_plugin(plugin_key)
|
||||
|
||||
@ -189,96 +189,21 @@ class LabelPrintMixin(LabelFilterMixin):
|
||||
|
||||
if len(items_to_print) == 0:
|
||||
# No valid items provided, return an error message
|
||||
|
||||
raise ValidationError('No valid objects provided to label template')
|
||||
|
||||
outputs = []
|
||||
# Label template
|
||||
label = self.get_object()
|
||||
|
||||
# In debug mode, generate single HTML output, rather than PDF
|
||||
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE', cache=False)
|
||||
# At this point, we offload the label(s) to the selected plugin.
|
||||
# The plugin is responsible for handling the request and returning a response.
|
||||
|
||||
label_name = "label.pdf"
|
||||
|
||||
label_names = []
|
||||
label_instances = []
|
||||
|
||||
# Merge one or more PDF files into a single download
|
||||
for item in items_to_print:
|
||||
label = self.get_object()
|
||||
label.object_to_print = item
|
||||
|
||||
label_name = label.generate_filename(request)
|
||||
|
||||
label_names.append(label_name)
|
||||
label_instances.append(label)
|
||||
|
||||
if debug_mode and plugin is None:
|
||||
# Note that debug mode is only supported when not using a plugin
|
||||
outputs.append(label.render_as_string(request))
|
||||
else:
|
||||
outputs.append(label.render(request))
|
||||
|
||||
if not label_name.endswith(".pdf"):
|
||||
label_name += ".pdf"
|
||||
|
||||
if plugin is not None:
|
||||
"""Label printing is to be handled by a plugin, rather than being exported to PDF.
|
||||
|
||||
In this case, we do the following:
|
||||
|
||||
- Individually generate each label, exporting as an image file
|
||||
- Pass all the images through to the label printing plugin
|
||||
- Return a JSON response indicating that the printing has been offloaded
|
||||
"""
|
||||
|
||||
for idx, output in enumerate(outputs):
|
||||
"""For each output, we generate a temporary image file, which will then get sent to the printer."""
|
||||
|
||||
# Generate PDF data for the label
|
||||
pdf = output.get_document().write_pdf()
|
||||
|
||||
# Offload a background task to print the provided label
|
||||
offload_task(
|
||||
plugin_label.print_label,
|
||||
plugin.plugin_slug(),
|
||||
pdf,
|
||||
filename=label_names[idx],
|
||||
label_instance=label_instances[idx],
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'plugin': plugin.plugin_slug(),
|
||||
'labels': label_names,
|
||||
})
|
||||
|
||||
elif debug_mode:
|
||||
"""Contatenate all rendered templates into a single HTML string, and return the string as a HTML response."""
|
||||
|
||||
html = "\n".join(outputs)
|
||||
|
||||
return HttpResponse(html)
|
||||
result = plugin.print_labels(label, items_to_print, request)
|
||||
|
||||
if isinstance(result, JsonResponse):
|
||||
result['plugin'] = plugin.plugin_slug()
|
||||
return result
|
||||
else:
|
||||
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!"""
|
||||
|
||||
pages = []
|
||||
|
||||
for output in outputs:
|
||||
doc = output.get_document()
|
||||
for page in doc.pages:
|
||||
pages.append(page)
|
||||
|
||||
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||
|
||||
inline = common.models.InvenTreeUserSetting.get_setting('LABEL_INLINE', user=request.user, cache=False)
|
||||
|
||||
return InvenTree.helpers.DownloadFile(
|
||||
pdf,
|
||||
label_name,
|
||||
content_type='application/pdf',
|
||||
inline=inline
|
||||
)
|
||||
raise ValidationError(f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'")
|
||||
|
||||
|
||||
class StockItemLabelMixin:
|
||||
|
26
InvenTree/label/migrations/0012_labeloutput.py
Normal file
26
InvenTree/label/migrations/0012_labeloutput.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.2.20 on 2023-07-14 11:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import label.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('label', '0011_auto_20230623_2158'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LabelOutput',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('label', models.FileField(unique=True, upload_to=label.models.rename_label_output)),
|
||||
('created', models.DateField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
@ -6,6 +6,7 @@ import os
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import FileExtensionValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.template import Context, Template
|
||||
@ -39,6 +40,13 @@ def rename_label(instance, filename):
|
||||
return os.path.join('label', 'template', instance.SUBDIR, filename)
|
||||
|
||||
|
||||
def rename_label_output(instance, filename):
|
||||
"""Place the label output file into the correct subdirectory."""
|
||||
filename = os.path.basename(filename)
|
||||
|
||||
return os.path.join('label', 'output', filename)
|
||||
|
||||
|
||||
def validate_stock_item_filters(filters):
|
||||
"""Validate query filters for the StockItemLabel model"""
|
||||
filters = validateFilterString(filters, model=stock.models.StockItem)
|
||||
@ -235,6 +243,36 @@ class LabelTemplate(MetadataMixin, models.Model):
|
||||
)
|
||||
|
||||
|
||||
class LabelOutput(models.Model):
|
||||
"""Class representing a label output file
|
||||
|
||||
'Printing' a label may generate a file object (such as PDF)
|
||||
which is made available for download.
|
||||
|
||||
Future work will offload this task to the background worker,
|
||||
and provide a 'progress' bar for the user.
|
||||
"""
|
||||
|
||||
# File will be stored in a subdirectory
|
||||
label = models.FileField(
|
||||
upload_to=rename_label_output,
|
||||
unique=True, blank=False, null=False,
|
||||
)
|
||||
|
||||
# Creation date of label output
|
||||
created = models.DateField(
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
# User who generated the label
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
)
|
||||
|
||||
|
||||
class StockItemLabel(LabelTemplate):
|
||||
"""Template for printing StockItem labels."""
|
||||
|
||||
|
16
InvenTree/label/tasks.py
Normal file
16
InvenTree/label/tasks.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""Background tasks for the label app"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from InvenTree.tasks import ScheduledTask, scheduled_task
|
||||
from label.models import LabelOutput
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def cleanup_old_label_outputs():
|
||||
"""Remove old label outputs from the database"""
|
||||
|
||||
# Remove any label outputs which are older than 30 days
|
||||
LabelOutput.objects.filter(created__lte=timezone.now() - timedelta(days=5)).delete()
|
@ -1,17 +1,21 @@
|
||||
"""Tests for labels"""
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.helpers import validateFilterString
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from label.models import LabelOutput
|
||||
from part.models import Part
|
||||
from plugin.registry import registry
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import PartLabel, StockItemLabel, StockLocationLabel
|
||||
@ -77,7 +81,16 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
|
||||
for label in labels:
|
||||
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
|
||||
self.get(f'{url}?parts={part.pk}', expected_code=200)
|
||||
|
||||
# Check that label printing returns the correct response type
|
||||
response = self.get(f'{url}?parts={part.pk}', expected_code=200)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertIn('message', data)
|
||||
self.assertIn('file', data)
|
||||
label_file = data['file']
|
||||
self.assertIn('/media/label/output/', label_file)
|
||||
|
||||
def test_print_part_label(self):
|
||||
"""Actually 'print' a label, and ensure that the correct information is contained."""
|
||||
@ -115,21 +128,33 @@ class LabelTest(InvenTreeAPITestCase):
|
||||
|
||||
# Ensure we are in "debug" mode (so the report is generated as HTML)
|
||||
InvenTreeSetting.set_setting('REPORT_ENABLE', True, None)
|
||||
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', True, None)
|
||||
|
||||
# Print via the API
|
||||
# Set the 'debug' setting for the plugin
|
||||
plugin = registry.get_plugin('inventreelabel')
|
||||
plugin.set_setting('DEBUG', True)
|
||||
|
||||
# Print via the API (Note: will default to the builtin plugin if no plugin supplied)
|
||||
url = reverse('api-part-label-print', kwargs={'pk': label.pk})
|
||||
|
||||
response = self.get(f'{url}?parts=1', expected_code=200)
|
||||
part_pk = Part.objects.first().pk
|
||||
|
||||
content = str(response.content)
|
||||
response = self.get(f'{url}?parts={part_pk}', expected_code=200)
|
||||
data = json.loads(response.content)
|
||||
self.assertIn('file', data)
|
||||
|
||||
# Find the generated file
|
||||
output = LabelOutput.objects.last()
|
||||
|
||||
# Open the file and read data
|
||||
with open(output.label.path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Test that each element has been rendered correctly
|
||||
self.assertIn("part: 1 - M2x4 LPHS", content)
|
||||
self.assertIn('data: {"part": 1}', content)
|
||||
self.assertIn(f'data: {{"part": {part_pk}}}', content)
|
||||
self.assertIn("http://testserver/part/1/", content)
|
||||
self.assertIn("image: /static/img/blank_image.png", content)
|
||||
self.assertIn("logo: /static/img/inventree.png", content)
|
||||
self.assertIn("img/blank_image.png", content)
|
||||
self.assertIn("img/inventree.png", content)
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the metadata field."""
|
||||
|
@ -5,30 +5,26 @@ import logging
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import pdf2image
|
||||
|
||||
import common.notifications
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.exceptions import log_error
|
||||
from plugin.registry import registry
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
def print_label(plugin_slug: str, pdf_data, filename=None, label_instance=None, user=None):
|
||||
def print_label(plugin_slug: str, **kwargs):
|
||||
"""Print label with the provided plugin.
|
||||
|
||||
This task is nominally handled by the background worker.
|
||||
If the printing fails (throws an exception) then the user is notified.
|
||||
|
||||
Args:
|
||||
Arguments:
|
||||
plugin_slug (str): The unique slug (key) of the plugin.
|
||||
pdf_data: Binary PDF data.
|
||||
filename: The intended name of the printed label. Defaults to None.
|
||||
label_instance (Union[LabelTemplate, None], optional): The template instance that should be printed. Defaults to None.
|
||||
user (Union[User, None], optional): User that should be informed of errors. Defaults to None.
|
||||
|
||||
kwargs:
|
||||
passed through to the plugin.print_label() method
|
||||
"""
|
||||
logger.info(f"Plugin '{plugin_slug}' is printing a label '{filename}'")
|
||||
logger.info(f"Plugin '{plugin_slug}' is printing a label")
|
||||
|
||||
plugin = registry.get_plugin(plugin_slug)
|
||||
|
||||
@ -36,43 +32,30 @@ def print_label(plugin_slug: str, pdf_data, filename=None, label_instance=None,
|
||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||
return
|
||||
|
||||
# In addition to providing a .pdf image, we'll also provide a .png file
|
||||
dpi = InvenTreeSetting.get_setting('LABEL_DPI', 300)
|
||||
png_file = pdf2image.convert_from_bytes(
|
||||
pdf_data,
|
||||
dpi=dpi,
|
||||
)[0]
|
||||
|
||||
try:
|
||||
plugin.print_label(
|
||||
pdf_data=pdf_data,
|
||||
png_file=png_file,
|
||||
filename=filename,
|
||||
label_instance=label_instance,
|
||||
width=label_instance.width,
|
||||
height=label_instance.height,
|
||||
user=user
|
||||
)
|
||||
plugin.print_label(**kwargs)
|
||||
except Exception as e: # pragma: no cover
|
||||
# Plugin threw an error - notify the user who attempted to print
|
||||
|
||||
ctx = {
|
||||
'name': _('Label printing failed'),
|
||||
'message': str(e),
|
||||
}
|
||||
|
||||
# Log an error message to the database
|
||||
log_error('plugin.print_label')
|
||||
logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover
|
||||
user = kwargs.get('user', None)
|
||||
|
||||
# Throw an error against the plugin instance
|
||||
common.notifications.trigger_notification(
|
||||
plugin.plugin_config(),
|
||||
'label.printing_failed',
|
||||
targets=[user],
|
||||
context=ctx,
|
||||
delivery_methods={common.notifications.UIMessageNotification, },
|
||||
)
|
||||
if user:
|
||||
# Log an error message to the database
|
||||
log_error('plugin.print_label')
|
||||
logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover
|
||||
|
||||
# Throw an error against the plugin instance
|
||||
common.notifications.trigger_notification(
|
||||
plugin.plugin_config(),
|
||||
'label.printing_failed',
|
||||
targets=[user],
|
||||
context=ctx,
|
||||
delivery_methods={common.notifications.UIMessageNotification, },
|
||||
)
|
||||
|
||||
if settings.TESTING:
|
||||
# If we are in testing mode, we want to know about this exception
|
||||
|
@ -1,5 +1,13 @@
|
||||
"""Plugin mixin classes for label plugins."""
|
||||
|
||||
from django.http import JsonResponse
|
||||
|
||||
import pdf2image
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.tasks import offload_task
|
||||
from label.models import LabelTemplate
|
||||
from plugin.base.label import label as plugin_label
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
|
||||
|
||||
@ -8,9 +16,16 @@ class LabelPrintingMixin:
|
||||
|
||||
Each plugin must provide a NAME attribute, which is used to uniquely identify the printer.
|
||||
|
||||
The plugin must also implement the print_label() function
|
||||
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.
|
||||
"""
|
||||
|
||||
# If True, the print_label() method will block until the label is printed
|
||||
# If False, the offload_label() method will be called instead
|
||||
# By default, this is False, which means that labels will be printed in the background
|
||||
BLOCKING_PRINT = False
|
||||
|
||||
class MixinMeta:
|
||||
"""Meta options for this mixin."""
|
||||
MIXIN_NAME = 'Label printing'
|
||||
@ -20,17 +35,124 @@ class LabelPrintingMixin:
|
||||
super().__init__()
|
||||
self.add_mixin('labels', True, __class__)
|
||||
|
||||
def render_to_pdf(self, label: LabelTemplate, request, **kwargs):
|
||||
"""Render this label to PDF format
|
||||
|
||||
Arguments:
|
||||
label: The LabelTemplate object to render
|
||||
request: The HTTP request object which triggered this print job
|
||||
"""
|
||||
return label.render(request)
|
||||
|
||||
def render_to_html(self, label: LabelTemplate, request, **kwargs):
|
||||
"""Render this label to HTML format
|
||||
|
||||
Arguments:
|
||||
label: The LabelTemplate object to render
|
||||
request: The HTTP request object which triggered this print job
|
||||
"""
|
||||
return label.render_as_string(request)
|
||||
|
||||
def render_to_png(self, label: LabelTemplate, request=None, **kwargs):
|
||||
"""Render this label to PNG format"""
|
||||
|
||||
# Check if pdf data is provided
|
||||
pdf_data = kwargs.get('pdf_data', None)
|
||||
|
||||
if not pdf_data:
|
||||
pdf_data = self.render_to_pdf(label, request, **kwargs).get_document().write_pdf()
|
||||
|
||||
dpi = kwargs.get(
|
||||
'dpi',
|
||||
InvenTreeSetting.get_setting('LABEL_DPI', 300)
|
||||
)
|
||||
|
||||
# Convert to png data
|
||||
png = pdf2image.convert_from_bytes(pdf_data, dpi=dpi)[0]
|
||||
return png
|
||||
|
||||
def print_labels(self, label: LabelTemplate, items: list, request, **kwargs):
|
||||
"""Print one or more labels with the provided template and items.
|
||||
|
||||
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
|
||||
|
||||
Returns:
|
||||
A JSONResponse object which indicates outcome to the user
|
||||
|
||||
The default implementation simply calls print_label() for each label, producing multiple single label output "jobs"
|
||||
but this can be overridden by the particular plugin.
|
||||
"""
|
||||
|
||||
try:
|
||||
user = request.user
|
||||
except AttributeError:
|
||||
user = None
|
||||
|
||||
# Generate a label output for each provided item
|
||||
for item in items:
|
||||
label.object_to_print = item
|
||||
filename = label.generate_filename(request)
|
||||
pdf_file = self.render_to_pdf(label, request, **kwargs)
|
||||
pdf_data = pdf_file.get_document().write_pdf()
|
||||
png_file = self.render_to_png(label, request, pdf_data=pdf_data, **kwargs)
|
||||
|
||||
print_args = {
|
||||
'pdf_file': pdf_file,
|
||||
'pdf_data': pdf_data,
|
||||
'png_file': png_file,
|
||||
'filename': filename,
|
||||
'label_instance': label,
|
||||
'item_instance': item,
|
||||
'user': user,
|
||||
'width': label.width,
|
||||
'height': label.height,
|
||||
}
|
||||
|
||||
if self.BLOCKING_PRINT:
|
||||
# Blocking print job
|
||||
self.print_label(**print_args)
|
||||
else:
|
||||
# Non-blocking print job
|
||||
self.offload_label(**print_args)
|
||||
|
||||
# Return a JSON response to the user
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'{len(items)} labels printed',
|
||||
})
|
||||
|
||||
def print_label(self, **kwargs):
|
||||
"""Callback to print a single label.
|
||||
"""Print a single label (blocking)
|
||||
|
||||
kwargs:
|
||||
pdf_file: The PDF file object of the rendered label (WeasyTemplateResponse object)
|
||||
pdf_data: Raw PDF data of the rendered label
|
||||
png_file: An in-memory PIL image file, rendered at 300dpi
|
||||
filename: The filename of this PDF label
|
||||
label_instance: The instance of the label model which triggered the print_label() method
|
||||
item_instance: The instance of the database model against which the label is printed
|
||||
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)
|
||||
filename: The filename of this PDF label
|
||||
user: The user who printed this label
|
||||
|
||||
Note that the supplied kwargs may be different if the plugin overrides the print_labels() method.
|
||||
"""
|
||||
# Unimplemented (to be implemented by the particular plugin class)
|
||||
raise MixinNotImplementedError('This Plugin must implement a `print_label` method')
|
||||
|
||||
def offload_label(self, **kwargs):
|
||||
"""Offload a single label (non-blocking)
|
||||
|
||||
Instead of immediately printing the label (which is a blocking process),
|
||||
this method should offload the label to a background worker process.
|
||||
|
||||
Offloads a call to the 'print_label' method (of this plugin) to a background worker.
|
||||
"""
|
||||
|
||||
offload_task(
|
||||
plugin_label.print_label,
|
||||
self.plugin_slug(),
|
||||
**kwargs
|
||||
)
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Unit tests for the label printing mixin."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from django.apps import apps
|
||||
@ -7,7 +8,6 @@ from django.urls import reverse
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from label.models import PartLabel, StockItemLabel, StockLocationLabel
|
||||
from part.models import Part
|
||||
@ -77,11 +77,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), 1)
|
||||
self.assertEqual(len(plugins), 2)
|
||||
|
||||
# But, it is not 'active'
|
||||
plugins = registry.with_mixin('labels', active=True)
|
||||
self.assertEqual(len(plugins), 0)
|
||||
self.assertEqual(len(plugins), 1)
|
||||
|
||||
def test_api(self):
|
||||
"""Test that we can filter the API endpoint by mixin."""
|
||||
@ -123,8 +123,8 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
data = response.data[0]
|
||||
self.assertEqual(len(response.data), 2)
|
||||
data = response.data[1]
|
||||
self.assertEqual(data['key'], 'samplelabel')
|
||||
|
||||
def test_printing_process(self):
|
||||
@ -160,9 +160,10 @@ class LabelMixinTests(InvenTreeAPITestCase):
|
||||
self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
|
||||
|
||||
# Print multiple parts without a plugin in debug mode
|
||||
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', True, None)
|
||||
response = self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
|
||||
self.assertIn('@page', str(response.content))
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertIn('file', data)
|
||||
|
||||
# Print no part
|
||||
self.get(self.do_url(None, plugin_ref, label), expected_code=400)
|
||||
|
0
InvenTree/plugin/builtin/labels/__init__.py
Normal file
0
InvenTree/plugin/builtin/labels/__init__.py
Normal file
96
InvenTree/plugin/builtin/labels/inventree_label.py
Normal file
96
InvenTree/plugin/builtin/labels/inventree_label.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""Default label printing plugin (supports PDF generation)"""
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.http import JsonResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from label.models import LabelOutput, LabelTemplate
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import LabelPrintingMixin, SettingsMixin
|
||||
|
||||
|
||||
class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
|
||||
"""Builtin plugin for label printing.
|
||||
|
||||
This plugin merges the selected labels into a single PDF file,
|
||||
which is made available for download.
|
||||
"""
|
||||
|
||||
NAME = "InvenTreeLabel"
|
||||
TITLE = _("InvenTree PDF label printer")
|
||||
DESCRIPTION = _("Provides native support for printing PDF labels")
|
||||
VERSION = "1.0.0"
|
||||
AUTHOR = _("InvenTree contributors")
|
||||
|
||||
BLOCKING_PRINT = True
|
||||
|
||||
SETTINGS = {
|
||||
'DEBUG': {
|
||||
'name': _('Debug mode'),
|
||||
'description': _('Enable debug mode - returns raw HTML instead of PDF'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
}
|
||||
|
||||
def print_labels(self, label: LabelTemplate, items: list, request, **kwargs):
|
||||
"""Handle printing of multiple labels
|
||||
|
||||
- Label outputs are concatenated together, and we return a single PDF file.
|
||||
- If DEBUG mode is enabled, we return a single HTML file.
|
||||
"""
|
||||
|
||||
debug = self.get_setting('DEBUG')
|
||||
|
||||
outputs = []
|
||||
output_file = None
|
||||
|
||||
for item in items:
|
||||
|
||||
label.object_to_print = item
|
||||
|
||||
outputs.append(self.print_label(label, request, debug=debug, **kwargs))
|
||||
|
||||
if self.get_setting('DEBUG'):
|
||||
html = '\n'.join(outputs)
|
||||
|
||||
output_file = ContentFile(html, 'labels.html')
|
||||
else:
|
||||
pages = []
|
||||
|
||||
# Following process is required to stitch labels together into a single PDF
|
||||
for output in outputs:
|
||||
doc = output.get_document()
|
||||
|
||||
for page in doc.pages:
|
||||
pages.append(page)
|
||||
|
||||
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||
|
||||
# Create label output file
|
||||
output_file = ContentFile(pdf, 'labels.pdf')
|
||||
|
||||
# Save the generated file to the database
|
||||
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_label(self, label: LabelTemplate, request, **kwargs):
|
||||
"""Handle printing of a single label.
|
||||
|
||||
Returns either a PDF or HTML output, depending on the DEBUG setting.
|
||||
"""
|
||||
|
||||
debug = kwargs.get('debug', self.get_setting('DEBUG'))
|
||||
|
||||
if debug:
|
||||
return self.render_to_html(label, request, **kwargs)
|
||||
else:
|
||||
return self.render_to_pdf(label, request, **kwargs)
|
@ -14,21 +14,22 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
||||
SLUG = "samplelabel"
|
||||
TITLE = "Sample Label Printer"
|
||||
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
|
||||
VERSION = "0.2"
|
||||
AUTHOR = "InvenTree contributors"
|
||||
VERSION = "0.3.0"
|
||||
|
||||
def print_label(self, **kwargs):
|
||||
"""Sample printing step.
|
||||
|
||||
Normally here the connection to the printer and transfer of the label would take place.
|
||||
"""
|
||||
|
||||
# Test that the expected kwargs are present
|
||||
print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})")
|
||||
print(f"Width: {kwargs['width']} x Height: {kwargs['height']}")
|
||||
|
||||
pdf_data = kwargs['pdf_data']
|
||||
png_file = kwargs['png_file']
|
||||
png_file = self.render_to_png(label=None, pdf_data=pdf_data)
|
||||
|
||||
filename = kwargs['filename']
|
||||
filename = 'label.pdf'
|
||||
|
||||
# Dump the PDF to a local file
|
||||
with open(filename, 'wb') as pdf_out:
|
||||
|
@ -62,12 +62,12 @@ function inventreeGet(url, filters={}, options={}) {
|
||||
url: url,
|
||||
type: 'GET',
|
||||
data: filters,
|
||||
dataType: 'json',
|
||||
contentType: 'application/json',
|
||||
dataType: options.dataType || 'json',
|
||||
contentType: options.contentType || 'application/json',
|
||||
async: (options.async == false) ? false : true,
|
||||
success: function(response) {
|
||||
success: function(response, status, xhr) {
|
||||
if (options.success) {
|
||||
options.success(response);
|
||||
options.success(response, status, xhr);
|
||||
}
|
||||
},
|
||||
error: function(xhr, ajaxOptions, thrownError) {
|
||||
|
@ -59,7 +59,6 @@ function selectLabel(labels, items, options={}) {
|
||||
</label>
|
||||
<div class='controls'>
|
||||
<select id='id_plugin' class='select form-control' name='plugin'>
|
||||
<option value='' title='{% trans "Export to PDF" %}'>{% trans "Export to PDF" %}</option>
|
||||
`;
|
||||
|
||||
plugins.forEach(function(plugin) {
|
||||
@ -207,19 +206,20 @@ function printLabels(options) {
|
||||
href += `${options.key}=${item}&`;
|
||||
});
|
||||
|
||||
if (data.plugin) {
|
||||
href += `plugin=${data.plugin}`;
|
||||
href += `plugin=${data.plugin}`;
|
||||
|
||||
inventreeGet(href, {}, {
|
||||
success: function(response) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.open(href);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
plural_name: options.plural_name,
|
||||
singular_name: options.singular_name,
|
||||
|
@ -196,6 +196,7 @@ class RuleSet(models.Model):
|
||||
'common_projectcode',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'label_labeloutput',
|
||||
'users_owner',
|
||||
|
||||
# Third-party tables
|
||||
|
@ -4,9 +4,83 @@ title: Label Mixin
|
||||
|
||||
## LabelPrintingMixin
|
||||
|
||||
The `LabelPrintingMixin` class enables plugins to print labels directly to a connected printer. Custom plugins can be written to support any printer backend.
|
||||
The `LabelPrintingMixin` class allows plugins to provide custom label printing functionality. The specific implementation of a label printing plugin is quite flexible, allowing for the following functions (as a starting point):
|
||||
|
||||
An example of this is the [inventree-brother-plugin](https://github.com/inventree/inventree-brother-plugin) which provides native support for the Brother QL and PT series of networked label printers.
|
||||
- Printing a single label to a file, and allowing user to download
|
||||
- Combining multiple labels onto a single page
|
||||
- Supporting proprietary label sheet formats
|
||||
- Offloading label printing to an external printer
|
||||
|
||||
### Entry Point
|
||||
|
||||
When printing labels against a particular plugin, the entry point is the `print_labels` method. The default implementation of this method iterates over each of the provided items, renders a PDF, and calls the `print_label` method for each item, providing the rendered PDF data.
|
||||
|
||||
Both the `print_labels` and `print_label` methods may be overridden by a plugin, allowing for complex functionality to be achieved.
|
||||
|
||||
For example, the `print_labels` method could be reimplemented to merge all labels into a single larger page, and return a single page for printing.
|
||||
|
||||
### Return Type
|
||||
|
||||
The `print_labels` method *must* return a JsonResponse object. If the method does not return such a response, an error will be raised by the server.
|
||||
|
||||
### File Generation
|
||||
|
||||
If the label printing plugin generates a real file, it should be stored as a `LabelOutput` instance in the database, and returned in the JsonResponse result under the 'file' key.
|
||||
|
||||
For example, the built-in `InvenTreeLabelPlugin` plugin generates a PDF file which contains all the provided labels concatenated together. A snippet of the code is shown below (refer to the source code for full details):
|
||||
|
||||
```python
|
||||
# Save the generated file to the database
|
||||
output = LabelOutput.objects.create(
|
||||
label=output_file,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'file': output.label.url,
|
||||
'success': True,
|
||||
'message': f'{len(items)} labels generated'
|
||||
})
|
||||
```
|
||||
|
||||
### Background Printing
|
||||
|
||||
For some label printing processes (such as offloading printing to an external networked printer) it may be preferable to utilize the background worker process, and not block the front-end server.
|
||||
The plugin provides an easy method to offload printing to the background thread.
|
||||
|
||||
Simply override the class attribute `BLOCKING_PRINT` as follows:
|
||||
|
||||
```python
|
||||
class MyPrinterPlugin(LabelPrintingMixin, InvenTreePlugin):
|
||||
BLOCKING_PRINT = False
|
||||
```
|
||||
|
||||
If the `print_labels` method is not changed, this will run the `print_label` method in a background worker thread.
|
||||
|
||||
!!! info "Example Plugin"
|
||||
Check out the [inventree-brother-plugin](https://github.com/inventree/inventree-brother-plugin) which provides native support for the Brother QL and PT series of networked label printers
|
||||
|
||||
!!! tip "Custom Code"
|
||||
If your plugin overrides the `print_labels` method, you will have to ensure that the label printing is correctly offloaded to the background worker. Look at the `offload_label` method of the plugin mixin class for how this can be achieved.
|
||||
|
||||
### Helper Methods
|
||||
|
||||
The plugin class provides a number of additional helper methods which may be useful for generating labels:
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| render_to_pdf | Render label template to an in-memory PDF object |
|
||||
| render_to_html | Render label template to a raw HTML string |
|
||||
| render_to_png | Convert PDF data to an in-memory PNG image |
|
||||
|
||||
!!! info "Use the Source"
|
||||
These methods are available for more complex implementations - refer to the source code for more information!
|
||||
|
||||
### Merging Labels
|
||||
|
||||
To merge (combine) multiple labels into a single output (for example printing multiple labels on a single sheet of paper), the plugin must override the `print_labels` method and implement the required functionality.
|
||||
|
||||
## Integration
|
||||
|
||||
### Web Integration
|
||||
|
||||
@ -22,7 +96,9 @@ Label printing plugins also allow direct printing of labels via the [mobile app]
|
||||
|
||||
## Implementation
|
||||
|
||||
Plugins which implement the `LabelPrintingMixin` mixin class must provide a `print_label` function:
|
||||
Plugins which implement the `LabelPrintingMixin` mixin class can be implemented by simply providing a `print_label` method.
|
||||
|
||||
### Simple Example
|
||||
|
||||
```python
|
||||
from dummy_printer import printer_backend
|
||||
@ -38,6 +114,9 @@ class MyLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
||||
SLUG = "mylabel"
|
||||
TITLE = "A dummy printer"
|
||||
|
||||
# Set BLOCKING_PRINT to false to return immediately
|
||||
BLOCKING_PRINT = False
|
||||
|
||||
def print_label(self, **kwargs):
|
||||
"""
|
||||
Send the label to the printer
|
||||
@ -59,6 +138,12 @@ class MyLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
||||
printer_backend.print(png_file, w=width, h=height)
|
||||
```
|
||||
|
||||
### Default Plugin
|
||||
|
||||
InvenTree supplies the `InvenTreeLabelPlugin` out of the box, which generates a PDF file which is then available for immediate download by the user.
|
||||
|
||||
The default plugin also features a *DEBUG* mode which generates a raw HTML output, rather than PDF. This can be handy for tracking down any template rendering errors in your labels.
|
||||
|
||||
### Available Data
|
||||
|
||||
The *label* data are supplied to the plugin in both `PDF` and `PNG` formats. This provides compatibility with a great range of label printers "out of the box". Conversion to other formats, if required, is left as an exercise for the plugin developer.
|
||||
|
Loading…
Reference in New Issue
Block a user