mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-03 02:21:34 +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:
.gitignore
InvenTree
InvenTree
common
label
plugin
base
builtin
samples
integration
templates
users
docs/docs/extend/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."""
|
||||
|
Reference in New Issue
Block a user