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:
Oliver 2023-07-17 21:39:53 +10:00 committed by GitHub
parent 4d7fb751eb
commit e8d16298a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 496 additions and 172 deletions

8
.gitignore vendored
View File

@ -40,9 +40,11 @@ inventree-demo-dataset/
inventree-data/ inventree-data/
dummy_image.* dummy_image.*
_tmp.csv _tmp.csv
inventree/label.pdf InvenTree/label.pdf
inventree/label.png InvenTree/label.png
inventree/my_special* label.pdf
label.png
InvenTree/my_special*
_tests*.txt _tests*.txt
# Local static and media file storage (only when running in development mode) # Local static and media file storage (only when running in development mode)

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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 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 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 - Changes 'serial_lte' and 'serial_gte' stock filters to point to 'serial_int' field

View File

@ -1794,7 +1794,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
def label_printer_options(): def label_printer_options():
"""Build a list of available 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') label_printer_plugins = registry.with_mixin('labels')
if label_printer_plugins: if label_printer_plugins:
printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins]) printers.extend([(p.slug, p.name + ' - ' + p.human_name) for p in label_printer_plugins])

View File

@ -2,7 +2,7 @@
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldError, ValidationError 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.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page, never_cache from django.views.decorators.cache import cache_page, never_cache
@ -18,9 +18,8 @@ import label.serializers
from InvenTree.api import MetadataView from InvenTree.api import MetadataView
from InvenTree.filters import InvenTreeSearchFilter from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from InvenTree.tasks import offload_task
from part.models import Part 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 plugin.registry import registry
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@ -167,9 +166,10 @@ class LabelPrintMixin(LabelFilterMixin):
plugin_key = request.query_params.get('plugin', None) plugin_key = request.query_params.get('plugin', None)
# No plugin provided, and that's OK # No plugin provided!
if plugin_key is None: 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) plugin = registry.get_plugin(plugin_key)
@ -189,96 +189,21 @@ class LabelPrintMixin(LabelFilterMixin):
if len(items_to_print) == 0: if len(items_to_print) == 0:
# No valid items provided, return an error message # No valid items provided, return an error message
raise ValidationError('No valid objects provided to label template') 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 # At this point, we offload the label(s) to the selected plugin.
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE', cache=False) # The plugin is responsible for handling the request and returning a response.
label_name = "label.pdf" result = plugin.print_labels(label, items_to_print, request)
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)
if isinstance(result, JsonResponse):
result['plugin'] = plugin.plugin_slug()
return result
else: else:
"""Concatenate all rendered pages into a single PDF object, and return the resulting document!""" raise ValidationError(f"Plugin '{plugin.plugin_slug()}' returned invalid response type '{type(result)}'")
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
)
class StockItemLabelMixin: class StockItemLabelMixin:

View 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)),
],
),
]

View File

@ -6,6 +6,7 @@ import os
import sys import sys
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.core.validators import FileExtensionValidator, MinValueValidator from django.core.validators import FileExtensionValidator, MinValueValidator
from django.db import models from django.db import models
from django.template import Context, Template from django.template import Context, Template
@ -39,6 +40,13 @@ def rename_label(instance, filename):
return os.path.join('label', 'template', instance.SUBDIR, 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): def validate_stock_item_filters(filters):
"""Validate query filters for the StockItemLabel model""" """Validate query filters for the StockItemLabel model"""
filters = validateFilterString(filters, model=stock.models.StockItem) 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): class StockItemLabel(LabelTemplate):
"""Template for printing StockItem labels.""" """Template for printing StockItem labels."""

16
InvenTree/label/tasks.py Normal file
View 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()

View File

@ -1,17 +1,21 @@
"""Tests for labels""" """Tests for labels"""
import io import io
import json
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.http import JsonResponse
from django.urls import reverse from django.urls import reverse
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.helpers import validateFilterString from InvenTree.helpers import validateFilterString
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import LabelOutput
from part.models import Part from part.models import Part
from plugin.registry import registry
from stock.models import StockItem from stock.models import StockItem
from .models import PartLabel, StockItemLabel, StockLocationLabel from .models import PartLabel, StockItemLabel, StockLocationLabel
@ -77,7 +81,16 @@ class LabelTest(InvenTreeAPITestCase):
for label in labels: for label in labels:
url = reverse('api-part-label-print', kwargs={'pk': label.pk}) 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): def test_print_part_label(self):
"""Actually 'print' a label, and ensure that the correct information is contained.""" """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) # Ensure we are in "debug" mode (so the report is generated as HTML)
InvenTreeSetting.set_setting('REPORT_ENABLE', True, None) 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}) 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 # Test that each element has been rendered correctly
self.assertIn("part: 1 - M2x4 LPHS", content) 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("http://testserver/part/1/", content)
self.assertIn("image: /static/img/blank_image.png", content) self.assertIn("img/blank_image.png", content)
self.assertIn("logo: /static/img/inventree.png", content) self.assertIn("img/inventree.png", content)
def test_metadata(self): def test_metadata(self):
"""Unit tests for the metadata field.""" """Unit tests for the metadata field."""

View File

@ -5,30 +5,26 @@ import logging
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import pdf2image
import common.notifications import common.notifications
from common.models import InvenTreeSetting
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from plugin.registry import registry from plugin.registry import registry
logger = logging.getLogger('inventree') 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. """Print label with the provided plugin.
This task is nominally handled by the background worker. This task is nominally handled by the background worker.
If the printing fails (throws an exception) then the user is notified. If the printing fails (throws an exception) then the user is notified.
Args: Arguments:
plugin_slug (str): The unique slug (key) of the plugin. 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. kwargs:
label_instance (Union[LabelTemplate, None], optional): The template instance that should be printed. Defaults to None. passed through to the plugin.print_label() method
user (Union[User, None], optional): User that should be informed of errors. Defaults to None.
""" """
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) 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}'") logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return 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: try:
plugin.print_label( plugin.print_label(**kwargs)
pdf_data=pdf_data,
png_file=png_file,
filename=filename,
label_instance=label_instance,
width=label_instance.width,
height=label_instance.height,
user=user
)
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
# Plugin threw an error - notify the user who attempted to print # Plugin threw an error - notify the user who attempted to print
ctx = { ctx = {
'name': _('Label printing failed'), 'name': _('Label printing failed'),
'message': str(e), 'message': str(e),
} }
# Log an error message to the database user = kwargs.get('user', None)
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 if user:
common.notifications.trigger_notification( # Log an error message to the database
plugin.plugin_config(), log_error('plugin.print_label')
'label.printing_failed', logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover
targets=[user],
context=ctx, # Throw an error against the plugin instance
delivery_methods={common.notifications.UIMessageNotification, }, common.notifications.trigger_notification(
) plugin.plugin_config(),
'label.printing_failed',
targets=[user],
context=ctx,
delivery_methods={common.notifications.UIMessageNotification, },
)
if settings.TESTING: if settings.TESTING:
# If we are in testing mode, we want to know about this exception # If we are in testing mode, we want to know about this exception

View File

@ -1,5 +1,13 @@
"""Plugin mixin classes for label plugins.""" """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 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. 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: class MixinMeta:
"""Meta options for this mixin.""" """Meta options for this mixin."""
MIXIN_NAME = 'Label printing' MIXIN_NAME = 'Label printing'
@ -20,17 +35,124 @@ class LabelPrintingMixin:
super().__init__() super().__init__()
self.add_mixin('labels', True, __class__) 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): def print_label(self, **kwargs):
"""Callback to print a single label. """Print a single label (blocking)
kwargs: kwargs:
pdf_file: The PDF file object of the rendered label (WeasyTemplateResponse object)
pdf_data: Raw PDF data of the rendered label 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 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) width: The expected width of the label (in mm)
height: The expected height 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) # Unimplemented (to be implemented by the particular plugin class)
raise MixinNotImplementedError('This Plugin must implement a `print_label` method') 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
)

View File

@ -1,5 +1,6 @@
"""Unit tests for the label printing mixin.""" """Unit tests for the label printing mixin."""
import json
import os import os
from django.apps import apps from django.apps import apps
@ -7,7 +8,6 @@ from django.urls import reverse
from PIL import Image from PIL import Image
from common.models import InvenTreeSetting
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from label.models import PartLabel, StockItemLabel, StockLocationLabel from label.models import PartLabel, StockItemLabel, StockLocationLabel
from part.models import Part from part.models import Part
@ -77,11 +77,11 @@ class LabelMixinTests(InvenTreeAPITestCase):
"""Test that the sample printing plugin is installed.""" """Test that the sample printing plugin is installed."""
# Get all label plugins # Get all label plugins
plugins = registry.with_mixin('labels') plugins = registry.with_mixin('labels')
self.assertEqual(len(plugins), 1) self.assertEqual(len(plugins), 2)
# But, it is not 'active' # But, it is not 'active'
plugins = registry.with_mixin('labels', active=True) plugins = registry.with_mixin('labels', active=True)
self.assertEqual(len(plugins), 0) self.assertEqual(len(plugins), 1)
def test_api(self): def test_api(self):
"""Test that we can filter the API endpoint by mixin.""" """Test that we can filter the API endpoint by mixin."""
@ -123,8 +123,8 @@ class LabelMixinTests(InvenTreeAPITestCase):
} }
) )
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 2)
data = response.data[0] data = response.data[1]
self.assertEqual(data['key'], 'samplelabel') self.assertEqual(data['key'], 'samplelabel')
def test_printing_process(self): 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) self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
# Print multiple parts without a plugin in debug mode # 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) 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 # Print no part
self.get(self.do_url(None, plugin_ref, label), expected_code=400) self.get(self.do_url(None, plugin_ref, label), expected_code=400)

View 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)

View File

@ -14,21 +14,22 @@ class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
SLUG = "samplelabel" SLUG = "samplelabel"
TITLE = "Sample Label Printer" TITLE = "Sample Label Printer"
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface" 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): def print_label(self, **kwargs):
"""Sample printing step. """Sample printing step.
Normally here the connection to the printer and transfer of the label would take place. Normally here the connection to the printer and transfer of the label would take place.
""" """
# Test that the expected kwargs are present # Test that the expected kwargs are present
print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})") print(f"Printing Label: {kwargs['filename']} (User: {kwargs['user']})")
print(f"Width: {kwargs['width']} x Height: {kwargs['height']}")
pdf_data = kwargs['pdf_data'] 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 # Dump the PDF to a local file
with open(filename, 'wb') as pdf_out: with open(filename, 'wb') as pdf_out:

View File

@ -62,12 +62,12 @@ function inventreeGet(url, filters={}, options={}) {
url: url, url: url,
type: 'GET', type: 'GET',
data: filters, data: filters,
dataType: 'json', dataType: options.dataType || 'json',
contentType: 'application/json', contentType: options.contentType || 'application/json',
async: (options.async == false) ? false : true, async: (options.async == false) ? false : true,
success: function(response) { success: function(response, status, xhr) {
if (options.success) { if (options.success) {
options.success(response); options.success(response, status, xhr);
} }
}, },
error: function(xhr, ajaxOptions, thrownError) { error: function(xhr, ajaxOptions, thrownError) {

View File

@ -59,7 +59,6 @@ function selectLabel(labels, items, options={}) {
</label> </label>
<div class='controls'> <div class='controls'>
<select id='id_plugin' class='select form-control' name='plugin'> <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) { plugins.forEach(function(plugin) {
@ -207,19 +206,20 @@ function printLabels(options) {
href += `${options.key}=${item}&`; href += `${options.key}=${item}&`;
}); });
if (data.plugin) { href += `plugin=${data.plugin}`;
href += `plugin=${data.plugin}`;
inventreeGet(href, {}, { inventreeGet(href, {}, {
success: function(response) { success: function(response) {
if (response.file) {
// Download the generated file
window.open(response.file);
} else {
showMessage('{% trans "Labels sent to printer" %}', { showMessage('{% trans "Labels sent to printer" %}', {
style: 'success', style: 'success',
}); });
} }
}); }
} else { });
window.open(href);
}
}, },
plural_name: options.plural_name, plural_name: options.plural_name,
singular_name: options.singular_name, singular_name: options.singular_name,

View File

@ -196,6 +196,7 @@ class RuleSet(models.Model):
'common_projectcode', 'common_projectcode',
'common_webhookendpoint', 'common_webhookendpoint',
'common_webhookmessage', 'common_webhookmessage',
'label_labeloutput',
'users_owner', 'users_owner',
# Third-party tables # Third-party tables

View File

@ -4,9 +4,83 @@ title: Label Mixin
## LabelPrintingMixin ## 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 ### Web Integration
@ -22,7 +96,9 @@ Label printing plugins also allow direct printing of labels via the [mobile app]
## Implementation ## 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 ```python
from dummy_printer import printer_backend from dummy_printer import printer_backend
@ -38,6 +114,9 @@ class MyLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
SLUG = "mylabel" SLUG = "mylabel"
TITLE = "A dummy printer" TITLE = "A dummy printer"
# Set BLOCKING_PRINT to false to return immediately
BLOCKING_PRINT = False
def print_label(self, **kwargs): def print_label(self, **kwargs):
""" """
Send the label to the printer Send the label to the printer
@ -59,6 +138,12 @@ class MyLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
printer_backend.print(png_file, w=width, h=height) 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 ### 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. 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.