From 967f7a55d1b27a0536273e02f99ff974db4ee22d Mon Sep 17 00:00:00 2001 From: Petr Ledvina Date: Sun, 27 Sep 2026 23:04:49 +0200 Subject: [PATCH] Add raise_error template tag (#12940) --- docs/docs/report/helpers.md | 20 ++++ docs/docs/report/labels.md | 20 ++++ .../InvenTree/report/templatetags/report.py | 13 +++ src/backend/InvenTree/report/test_tags.py | 31 +++++- src/backend/InvenTree/report/tests.py | 100 ++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index a43d8cdab6..150ccfe9d3 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -14,6 +14,26 @@ Some common functions are provided for use in custom report and label templates. !!! tip "Use the Source, Luke" To see the full range of available helper functions, refer to the source file [report.py]({{ sourcefile("src/backend/InvenTree/report/templatetags/report.py") }}) where these functions are defined! +## Raising Template Errors + +Use `raise_error` to stop rendering a report or label template with an actionable error message. The message is required and can be a quoted string or a template variable. + +::: report.templatetags.report.raise_error + options: + show_docstring_description: false + show_source: False + +```html +{% raw %} +{% load report %} +{% if not stock_item.serial %} + {% raise_error "Serial number is required for this label" %} +{% endif %} +{% endraw %} +``` + +The tag raises a validation error. For labels, how this error affects printing depends on the printing plugin. See [label validation](./labels.md#validating-label-data) for sheet and batch behavior. + ## Assigning Variables When making use of helper functions within a template, it can be useful to store the result of the function to a variable, rather than immediately rendering the output. diff --git a/docs/docs/report/labels.md b/docs/docs/report/labels.md index cdaf7136d2..c905fb1b48 100644 --- a/docs/docs/report/labels.md +++ b/docs/docs/report/labels.md @@ -120,6 +120,26 @@ OUT OF STOCK {% endraw %} ``` +### Validating Label Data + +Use the [raise_error helper](./helpers.md#raising-template-errors) to reject data which cannot produce a valid label: + +```html +{% raw %} +{% load report %} +{% if not stock_item.serial %} + {% raise_error "Serial number is required for this label" %} +{% endif %} +{% endraw %} +``` + +When evaluated, the tag raises a validation error and stops rendering the current label. With the InvenTree PDF label printer, this stops the print job and retains the supplied message in the failed print result. Correct the data and submit a new print job to print again. + +The InvenTree Label Sheet Printer handles this like other rendering errors: it logs the error, inserts an error cell, and continues generating the sheet. The tag does not abort the entire sheet job. + +!!! warning "Batch printing" + Validation happens when each template is evaluated. Earlier labels may already have printed or been queued by the printing plugin. This does not validate the entire batch before printing or undo earlier printing. Custom plugins must propagate template validation errors for this behavior to apply. + ### Label Filters Each label template provides a set of programmable filters which can be used to determine the relevance of that particular label. It may be the case that a particular label template is only applicable if certain conditions are met. diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index fdf620c8e2..e00d6144b6 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -49,6 +49,19 @@ register = template.Library() logger = logging.getLogger('inventree') +@register.simple_tag() +def raise_error(message: str): + """Stop rendering a report or label template with an error message. + + Arguments: + message: The message to include in the validation error. + + Raises: + ValidationError: Whenever this tag is evaluated. + """ + raise ValidationError(message) + + def get_locale(locale: Optional[str] = None) -> Locale: """Resolve and return a babel Locale. diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index 9303f88ebb..4845c5155d 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -12,7 +12,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.template import Context, Template -from django.test import TestCase, override_settings +from django.test import SimpleTestCase, TestCase, override_settings from django.utils import timezone from django.utils.safestring import SafeString @@ -28,6 +28,35 @@ from report.templatetags import barcode as barcode_tags from report.templatetags import report as report_tags +class RaiseErrorTagTest(SimpleTestCase): + """Test deliberate validation failures in templates.""" + + def test_raise_error(self): + """Literal and variable messages are preserved by the template tag.""" + message = 'Serial number is required for this label' + + for argument in [f'"{message}"', 'error_message']: + with self.subTest(argument=argument): + template = Template( + '{% load report %}{% raise_error ' + argument + ' %}' + ) + with self.assertRaises(ValidationError) as raised: + template.render(Context({'error_message': message})) + + self.assertEqual(raised.exception.messages, [message]) + + def test_conditional_failure(self): + """A tag inside a false condition does not stop rendering.""" + template = Template( + '{% load report %}' + '{% if not stock_item.serial %}{% raise_error "Serial required" %}{% endif %}' + '{{ stock_item.serial }}' + ) + self.assertEqual( + template.render(Context({'stock_item': {'serial': '123'}})), '123' + ) + + class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): """Unit tests for the report template tags.""" diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index cf3504bea9..3921c6d50b 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -19,6 +19,7 @@ from django.test import TestCase from django.urls import reverse from django.utils.timezone import now +from error_report.models import Error from pypdf import PdfReader import report.models as report_models @@ -814,6 +815,105 @@ class LabelTest(InvenTreeAPITestCase): self.assertEqual(result['part__active'], 'False') +class LabelValidationTest(InvenTreeAPITestCase): + """Exercise template validation through label rendering and worker tasks.""" + + fixtures = ['category', 'part', 'location', 'stock'] + superuser = True + + def setUp(self): + """Create a label which requires a serial number.""" + super().setUp() + cache.clear() + self.message = 'Serial number is required for this label' + self.template = LabelTemplate.objects.create( + name='Serial validation label', + model_type='stockitem', + template=ContentFile( + '{% load report %}' + '{% if not stock_item.serial %}' + '{% raise_error "' + self.message + '" %}' + '{% endif %}Serial: {{ stock_item.serial }}', + name='SerialValidationLabel.html', + ), + ) + self.valid = StockItem.objects.get(pk=105) + self.invalid = StockItem.objects.get(pk=100) + self.later = StockItem.objects.get(pk=501) + + def test_render_validation(self): + """Both wrappers log and preserve failures from an actual template.""" + plugin = registry.get_plugin('inventreelabel') + + for wrapper in ['render_to_pdf', 'render_to_html']: + with ( + self.subTest(wrapper=wrapper), + patch('plugin.base.label.mixins.log_error') as log_error, + self.assertRaises(ValidationError) as raised, + ): + getattr(plugin, wrapper)(self.template, self.invalid, None) + + self.assertEqual(raised.exception.messages, [self.message]) + log_error.assert_called_once_with(wrapper, plugin=plugin.slug) + + def test_worker_validation(self): + """Real template failures stop rendering and persist their diagnostics.""" + plugin = registry.get_plugin('inventreelabel') + output = report_models.DataOutput.objects.create( + output_type=report_models.DataOutput.DataOutputTypes.LABEL, + template_name=self.template.name, + plugin=plugin.slug, + total=3, + ) + diagnostics = Error.objects.filter( + path=f'plugin.{plugin.slug}.render_to_pdf', info__contains=self.message + ) + error_count = diagnostics.count() + item_ids = [self.valid.pk, self.invalid.pk, self.later.pk] + + with ( + patch.object( + LabelTemplate, + 'render_as_string', + side_effect=self.template.render_as_string, + ) as render, + self.assertRaises(ValidationError) as raised, + ): + print_labels( + self.template.pk, + item_ids, + output.pk, + self.user.pk, + plugin.slug, + options={}, + ) + + self.assertEqual(raised.exception.messages, [self.message]) + self.assertEqual( + [call.args[0].pk for call in render.call_args_list], item_ids[:2] + ) + self.assertEqual(diagnostics.count(), error_count + 1) + output.refresh_from_db() + self.assertEqual(output.errors, {'error': self.message}) + self.assertFalse(output.complete) + self.assertFalse(output.output) + + def test_partial_batch(self): + """A later invalid label cannot undo an earlier print.""" + plugin = registry.get_plugin('inventreelabel') + + with ( + patch.object(plugin, 'print_label') as print_label, + patch.object(plugin, 'get_generated_file') as get_generated_file, + self.assertRaises(ValidationError), + ): + self.template.print([self.valid, self.invalid, self.later], plugin) + + print_label.assert_called_once() + self.assertEqual(print_label.call_args.kwargs['item_instance'], self.valid) + get_generated_file.assert_not_called() + + class PrintTestMixins: """Mixin that enables e2e printing tests."""