Preserve label printing failures and skip failed job redelivery (#12872)

Retain DataOutput records with the original printing error and mark jobs
failed when their plugin is unavailable. Keep exception propagation and
skip failed outputs on redelivery to avoid printing partial batches again.

Cover direct and worker exceptions, unavailable plugins, failed-job
redelivery, and missing or completed outputs.
This commit is contained in:
Petr Ledvina
2026-09-22 21:57:45 +10:00
committed by GitHub
parent 499c4d5b56
commit 9daf4f8efe
3 changed files with 185 additions and 3 deletions
+2 -2
View File
@@ -844,10 +844,10 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
if hasattr(plugin, 'after_printing'): if hasattr(plugin, 'after_printing'):
plugin.after_printing() plugin.after_printing()
except ValidationError as e: except ValidationError as e:
output.delete() output.mark_failure(error=', '.join(e.messages))
raise e raise e
except Exception as e: except Exception as e:
output.delete() output.mark_failure(error=f'{_("Error printing labels")}: {e}')
InvenTree.exceptions.log_error('print_labels', plugin=plugin.slug) InvenTree.exceptions.log_error('print_labels', plugin=plugin.slug)
raise ValidationError([_('Error printing labels'), str(e)]) raise ValidationError([_('Error printing labels'), str(e)])
+8
View File
@@ -118,6 +118,13 @@ def print_labels(
) )
return return
if output.errors:
# A failed job may have printed some labels already - do not print them again
logger.info(
'DataOutput %s has already failed - skipping print_labels task', output_id
)
return
# Fetch user information # Fetch user information
user = None user = None
@@ -141,6 +148,7 @@ def print_labels(
if not plugin: if not plugin:
logger.warning("Label printing plugin '%s' not found", plugin_slug) logger.warning("Label printing plugin '%s' not found", plugin_slug)
output.mark_failure(error=f"Label printing plugin '{plugin_slug}' not found")
return return
# Extract optional arguments for label printing # Extract optional arguments for label printing
+175 -1
View File
@@ -5,12 +5,13 @@ import socket
import tempfile import tempfile
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import Mock, patch
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.template.loader import render_to_string from django.template.loader import render_to_string
@@ -30,6 +31,7 @@ from order.models import PurchaseOrder, ReturnOrder, SalesOrder
from part.models import Part from part.models import Part
from plugin.registry import registry from plugin.registry import registry
from report.models import LabelTemplate, ReportTemplate from report.models import LabelTemplate, ReportTemplate
from report.tasks import print_labels
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@@ -547,6 +549,178 @@ class LabelTest(InvenTreeAPITestCase):
output = plugin.get_generated_file(**kwargs) output = plugin.get_generated_file(**kwargs)
self.assertEqual(output.name, expected) self.assertEqual(output.name, expected)
def test_print_failure(self):
"""Printing failures retain their details and propagate to the caller."""
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
parts = list(Part.objects.all()[:1])
for worker in [False, True]:
for error in [
ValidationError(['Invalid label data', 'Missing part number']),
RuntimeError('printer unavailable'),
]:
with self.subTest(worker=worker, error=type(error).__name__):
plugin = Mock(spec=['slug', 'print_labels'], slug='failing-label')
plugin.print_labels.side_effect = error
output = report_models.DataOutput.objects.create(
output_type=report_models.DataOutput.DataOutputTypes.LABEL,
template_name=template.name,
plugin=plugin.slug,
)
with (
patch.object(registry, 'get_plugin', return_value=plugin),
self.assertRaises(ValidationError) as raised,
):
if worker:
print_labels(
template.pk,
[part.pk for part in parts],
output.pk,
self.user.pk,
plugin.slug,
options={},
)
else:
template.print(parts, plugin, output=output)
plugin.print_labels.assert_called_once()
output.refresh_from_db()
self.assertFalse(output.complete)
self.assertFalse(output.output)
if isinstance(error, ValidationError):
self.assertIs(raised.exception, error)
self.assertEqual(
output.errors['error'], ', '.join(error.messages)
)
else:
self.assertEqual(
raised.exception.messages,
['Error printing labels', 'printer unavailable'],
)
self.assertEqual(
output.errors['error'],
'Error printing labels: printer unavailable',
)
if worker:
original = (
report_models.DataOutput.objects
.filter(pk=output.pk)
.values()
.get()
)
with patch.object(
registry, 'get_plugin', return_value=plugin
) as get_plugin:
print_labels(
template.pk,
[part.pk for part in parts],
output.pk,
self.user.pk,
plugin.slug,
options={},
)
get_plugin.assert_not_called()
plugin.print_labels.assert_called_once()
self.assertEqual(
report_models.DataOutput.objects
.filter(pk=output.pk)
.values()
.get(),
original,
)
def test_print_unavailable_plugin(self):
"""An unavailable worker plugin records a failure instead of hanging."""
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
output = report_models.DataOutput.objects.create(
output_type=report_models.DataOutput.DataOutputTypes.LABEL,
template_name=template.name,
plugin='unavailable-label',
)
with (
patch.object(registry, 'get_plugin', return_value=None) as get_plugin,
patch.object(LabelTemplate, 'print') as print_template,
):
print_labels(
template.pk, [], output.pk, self.user.pk, output.plugin, options={}
)
get_plugin.assert_called_once_with(output.plugin, active=True)
print_template.assert_not_called()
output.refresh_from_db()
self.assertFalse(output.complete)
self.assertFalse(output.output)
self.assertEqual(
output.errors,
{'error': "Label printing plugin 'unavailable-label' not found"},
)
original = report_models.DataOutput.objects.filter(pk=output.pk).values().get()
plugin = Mock(spec=['slug', 'print_labels'], slug=output.plugin)
with (
patch.object(registry, 'get_plugin', return_value=plugin) as get_plugin,
patch.object(LabelTemplate, 'print') as print_template,
):
print_labels(
template.pk, [], output.pk, self.user.pk, output.plugin, options={}
)
get_plugin.assert_not_called()
print_template.assert_not_called()
self.assertEqual(
report_models.DataOutput.objects.filter(pk=output.pk).values().get(),
original,
)
def test_print_task_duplicate(self):
"""Duplicate tasks skip completed or deleted outputs before plugin lookup."""
template = LabelTemplate.objects.filter(enabled=True, model_type='part').first()
for deleted in [False, True]:
with self.subTest(deleted=deleted):
output = report_models.DataOutput.objects.create(
complete=True, progress=100, output='data_output/labels.pdf'
)
output_id = output.pk
original = (
report_models.DataOutput.objects.filter(pk=output_id).values().get()
)
if deleted:
output.delete()
with (
patch.object(registry, 'get_plugin') as get_plugin,
patch.object(LabelTemplate, 'print') as print_template,
):
print_labels(
template.pk,
[],
output_id,
self.user.pk,
'inventreelabel',
options={},
)
get_plugin.assert_not_called()
print_template.assert_not_called()
if deleted:
self.assertFalse(
report_models.DataOutput.objects.filter(pk=output_id).exists()
)
else:
self.assertEqual(
report_models.DataOutput.objects
.filter(pk=output_id)
.values()
.get(),
original,
)
def test_print_custom_template(self): def test_print_custom_template(self):
"""Test printing against a custom template file.""" """Test printing against a custom template file."""
template_string = """ template_string = """