Fix validation of dependent serializer fields (#12871)

This commit is contained in:
Petr Ledvina
2026-09-18 07:59:44 +10:00
committed by GitHub
parent e37045ac41
commit b9c7c24f8a
3 changed files with 145 additions and 2 deletions
@@ -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.""" """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() self.get_child()
if self.child: if self.child:
return self.child.to_internal_value(data) return self.child.run_validation(data)
return None return None
@@ -3,21 +3,110 @@
import sys import sys
import threading import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import SimpleTestCase
from django.urls import path, reverse from django.urls import path, reverse
from rest_framework import serializers
from rest_framework.serializers import SerializerMethodField from rest_framework.serializers import SerializerMethodField
import InvenTree.serializers import InvenTree.serializers
from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin 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.unit_test import InvenTreeAPITestCase
from InvenTree.urls import backendpatterns from InvenTree.urls import backendpatterns
from part.models import Part 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( class SampleSerializer(
InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.FilterableSerializerMixin,
InvenTree.serializers.InvenTreeModelSerializer, InvenTree.serializers.InvenTreeModelSerializer,
@@ -9,9 +9,12 @@ from django.urls import reverse
from pdfminer.high_level import extract_text from pdfminer.high_level import extract_text
from PIL import Image from PIL import Image
from rest_framework import serializers
from InvenTree.config import get_testfolder_dir from InvenTree.config import get_testfolder_dir
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from machine import registry as machine_registry
from machine.models import MachineConfig
from part.models import Part from part.models import Part
from plugin import InvenTreePlugin, PluginMixinEnum, registry from plugin import InvenTreePlugin, PluginMixinEnum, registry
from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.label.mixins import LabelPrintingMixin
@@ -269,6 +272,57 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
print_label.call_args.kwargs['printing_options'], {'amount': 13} 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): def test_printing_endpoints(self):
"""Cover the endpoints not covered by `test_printing_process`.""" """Cover the endpoints not covered by `test_printing_process`."""
# Activate the label components # Activate the label components