From b9c7c24f8a0080d953f7efe20ef160bb7b03f32f Mon Sep 17 00:00:00 2001 From: Petr Ledvina Date: Thu, 17 Sep 2026 23:59:44 +0200 Subject: [PATCH] Fix validation of dependent serializer fields (#12871) --- .../InvenTree/InvenTree/serializers.py | 2 +- .../InvenTree/InvenTree/test_serializers.py | 91 ++++++++++++++++++- .../plugin/base/label/test_label_mixin.py | 54 +++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 663cf61f51..19a097ec32 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -693,7 +693,7 @@ class DependentField(serializers.Field): """This method tries to convert the data to an internal representation based on the defined to_internal_value method on the child.""" self.get_child() if self.child: - return self.child.to_internal_value(data) + return self.child.run_validation(data) return None diff --git a/src/backend/InvenTree/InvenTree/test_serializers.py b/src/backend/InvenTree/InvenTree/test_serializers.py index 9228cc6bbb..e815378065 100644 --- a/src/backend/InvenTree/InvenTree/test_serializers.py +++ b/src/backend/InvenTree/InvenTree/test_serializers.py @@ -3,21 +3,110 @@ import sys import threading from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace from django.contrib import admin from django.contrib.auth.models import User +from django.test import SimpleTestCase from django.urls import path, reverse +from rest_framework import serializers from rest_framework.serializers import SerializerMethodField import InvenTree.serializers from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin -from InvenTree.serializers import OptionalField +from InvenTree.serializers import DependentField, OptionalField from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.urls import backendpatterns from part.models import Part +class DependentFieldTests(SimpleTestCase): + """Test validation of dynamically selected child fields and serializers.""" + + def get_serializer(self, child, value): + """Bind a dependent child using the same data as the request.""" + + class ParentSerializer(serializers.Serializer): + kind = serializers.CharField() + options = DependentField( + depends_on=['kind'], field_serializer='get_options' + ) + + def get_options(self, fields): + return child + + data = {'kind': 'test', 'options': value} + return ParentSerializer( + data=data, context={'request': SimpleNamespace(data=data)} + ) + + def test_child_field_validators(self): + """Run validators on the selected child field.""" + serializer = self.get_serializer(serializers.IntegerField(min_value=1), '0') + + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors['options'][0].code, 'min_value') + + def test_child_serializer_validation(self): + """Propagate object-level validation errors under the dependent field.""" + + class OptionsSerializer(serializers.Serializer): + copies = serializers.IntegerField() + + def validate(self, attrs): + raise serializers.ValidationError('Invalid options', code='options') + + serializer = self.get_serializer(OptionsSerializer(), {'copies': 1}) + + with self.assertRaises(serializers.ValidationError) as error: + serializer.is_valid(raise_exception=True) + + detail = error.exception.detail['options']['non_field_errors'][0] + self.assertEqual(detail, 'Invalid options') + self.assertEqual(detail.code, 'options') + + def test_valid_child_field(self): + """Preserve conversion of valid child field input.""" + serializer = self.get_serializer(serializers.IntegerField(min_value=1), '2') + + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data['options'], 2) + + def test_valid_child_serializer(self): + """Use the data returned by the child serializer's validate method.""" + + class OptionsSerializer(serializers.Serializer): + copies = serializers.IntegerField() + + def validate(self, attrs): + attrs['copies'] *= 2 + return attrs + + serializer = self.get_serializer(OptionsSerializer(), {'copies': '2'}) + + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.validated_data['options'], {'copies': 4}) + + def test_child_conversion_error(self): + """Preserve ordinary child field conversion errors.""" + serializer = self.get_serializer(serializers.IntegerField(), 'invalid') + + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors['options'][0].code, 'invalid') + + def test_child_serializer_field_error(self): + """Preserve nested field errors from a child serializer.""" + + class OptionsSerializer(serializers.Serializer): + copies = serializers.IntegerField() + + serializer = self.get_serializer(OptionsSerializer(), {'copies': 'invalid'}) + + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors['options']['copies'][0].code, 'invalid') + + class SampleSerializer( InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer, diff --git a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py index f273eeb430..f64734ee53 100644 --- a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py +++ b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py @@ -9,9 +9,12 @@ from django.urls import reverse from pdfminer.high_level import extract_text from PIL import Image +from rest_framework import serializers from InvenTree.config import get_testfolder_dir from InvenTree.unit_test import InvenTreeAPITestCase +from machine import registry as machine_registry +from machine.models import MachineConfig from part.models import Part from plugin import InvenTreePlugin, PluginMixinEnum, registry from plugin.base.label.mixins import LabelPrintingMixin @@ -269,6 +272,57 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase): print_label.call_args.kwargs['printing_options'], {'amount': 13} ) + def test_machine_driver_options_validate_before_print_task(self): + """Test that machine driver option validation runs before the print task.""" + self.ensurePluginsLoaded() + apps.get_app_config('report').create_default_labels() + machine_registry.initialize() + registry.set_plugin_state('label-printer-test-plugin', True) + + machine_config = MachineConfig.objects.create( + machine_type='label-printer', + driver='test-label-printer-api', + name='Test label printer', + active=True, + ) + machine = machine_registry.get_machine(str(machine_config.pk)) + self.assertIsNotNone(machine) + + template = LabelTemplate.objects.filter(enabled=True, model_type='part').first() + assert template + part = Part.objects.first() + assert part + + class RejectingOptionsSerializer(serializers.Serializer): + copies = serializers.IntegerField(required=False, default=1) + + def validate(self, attrs): + raise serializers.ValidationError('preflight failed') + + driver = machine.driver + with ( + mock.patch('InvenTree.tasks.offload_task') as offload_task, + mock.patch.object( + driver, + 'get_printing_options_serializer', + side_effect=lambda *args, **kwargs: RejectingOptionsSerializer(), + ), + ): + response = self.post( + self.printing_url, + { + 'plugin': 'inventreelabelmachine', + 'template': template.pk, + 'items': [part.pk], + 'machine': str(machine_config.pk), + 'driver_options': {'copies': 1}, + }, + expected_code=400, + ) + + offload_task.assert_not_called() + self.assertIn('preflight failed', str(response.data)) + def test_printing_endpoints(self): """Cover the endpoints not covered by `test_printing_process`.""" # Activate the label components