From f840ed81d19982b2c59d5cc9c698fcba0a41310d Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 14 Sep 2026 20:19:20 -0400 Subject: [PATCH] Fixes #23154: Correct required=False mismatch on L2VPN.type and RackType.form_factor Both are non-nullable CharFields with choices, no default, and blank=False -- so the model genuinely requires a value, but their serializer fields were declared (or inherited) as optional, causing OPTIONS to advertise them as required=False while POST rejects their omission with a confusing model-level "This field cannot be blank." error instead of a normal field-required error. - vpn.L2VPN.type: the serializer explicitly set required=False on a field with no model default; changed to required=True. - dcim.RackType.form_factor: inherited its field declaration from RackBaseSerializer, which is correct for Rack.form_factor (genuinely optional/nullable) but wrong for RackType.form_factor (required). Added an override in RackTypeSerializer with no required/allow_blank/allow_null kwargs, so it defaults to required=True like every other undeclared ChoiceField. Searched the rest of the codebase for the same shape (choices field, no default, blank=False, with an explicit required=False override) -- these were the only two instances. Adds a regression test to each app's test_api.py asserting OPTIONS reports required=True and a POST omitting the field returns 400 naming the field. --- netbox/dcim/api/serializers_/racks.py | 3 +++ netbox/dcim/tests/test_api.py | 22 ++++++++++++++++++++++ netbox/vpn/api/serializers_/l2vpn.py | 2 +- netbox/vpn/tests/test_api.py | 20 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index a088333e2..e021e78f4 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -86,6 +86,9 @@ class RackBaseSerializer(PrimaryModelSerializer): class RackTypeSerializer(RackBaseSerializer): + # Unlike Rack.form_factor (optional & nullable), RackType.form_factor is required + # (blank=False, no default), so override RackBaseSerializer's optional declaration. + form_factor = ChoiceField(choices=RackFormFactorChoices) manufacturer = ManufacturerSerializer(nested=True) rack_count = serializers.IntegerField(read_only=True) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5ba406ee5..74588d2c2 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1206,6 +1206,28 @@ class RackTypeTestCase(APIViewTestCases.APIViewTestCase): }, ] + def test_form_factor_required(self): + """ + Regression test for #23154: form_factor must be reported as required by + OPTIONS, and a POST omitting it must be rejected with a normal "required" + validation error rather than a model-level "cannot be blank" error. + """ + self.add_permissions('dcim.add_racktype') + + response = self.client.options(self._get_list_url(), **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertTrue(response.data['actions']['POST']['form_factor']['required']) + + manufacturer = Manufacturer.objects.first() + data = { + 'manufacturer': manufacturer.pk, + 'model': 'Rack Type Missing Form Factor', + 'slug': 'rack-type-missing-form-factor', + } + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('form_factor', response.data) + class RackTestCase(APIViewTestCases.APIViewTestCase): model = Rack diff --git a/netbox/vpn/api/serializers_/l2vpn.py b/netbox/vpn/api/serializers_/l2vpn.py index 874ae342e..b02fa958e 100644 --- a/netbox/vpn/api/serializers_/l2vpn.py +++ b/netbox/vpn/api/serializers_/l2vpn.py @@ -16,7 +16,7 @@ __all__ = ( class L2VPNSerializer(PrimaryModelSerializer): - type = ChoiceField(choices=L2VPNTypeChoices, required=False) + type = ChoiceField(choices=L2VPNTypeChoices, required=True) import_targets = SerializedPKRelatedField( queryset=RouteTarget.objects.all(), serializer=RouteTargetSerializer, diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index 25695bbf0..2db24f65e 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -601,6 +601,26 @@ class L2VPNTestCase(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response_data['count'], 1) + def test_type_required(self): + """ + Regression test for #23154: type must be reported as required by OPTIONS, + and a POST omitting it must be rejected with a normal "required" validation + error rather than a model-level "cannot be blank" error. + """ + self.add_permissions('vpn.add_l2vpn') + + response = self.client.options(self._get_list_url(), **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertTrue(response.data['actions']['POST']['type']['required']) + + data = { + 'name': 'L2VPN Missing Type', + 'slug': 'l2vpn-missing-type', + } + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('type', response.data) + class L2VPNTerminationTestCase(APIViewTestCases.APIViewTestCase): model = L2VPNTermination