From 875e7d0885f7bdf9c5045fe8824d57e60f2c4621 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Wed, 19 Aug 2026 22:35:01 +0200 Subject: [PATCH] fix(api): Allow Module Bay Types to be written via the REST API (#22984) Declare module_bay_types as a SerializedPKRelatedField on the Module Bay, Module Bay Template, and Module Type serializers. This accepts primary keys on write while preserving the nested representation on read and avoids DRF's writable-nested assertion. Because automatic serializer prefetch discovery no longer recurses into this field, add manufacturer prefetches to the corresponding viewsets. Populate the API test fixtures with Module Bay Types so the existing list query-count tests cover the relationship and guard against N+1 queries. Fixes #22982 --- .../api/serializers_/device_components.py | 7 ++- .../api/serializers_/devicetype_components.py | 9 ++- netbox/dcim/api/serializers_/devicetypes.py | 8 ++- netbox/dcim/api/views.py | 6 +- netbox/dcim/tests/query_counts.json | 6 +- netbox/dcim/tests/test_api.py | 63 +++++++++++++++++++ 6 files changed, 85 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 04ae102af..b30ca023d 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -15,6 +15,7 @@ from dcim.models import ( Interface, InventoryItem, ModuleBay, + ModuleBayType, PortMapping, PowerOutlet, PowerPort, @@ -530,10 +531,12 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer): required=False, allow_null=True ) - module_bay_types = ModuleBayTypeSerializer( + module_bay_types = SerializedPKRelatedField( + queryset=ModuleBayType.objects.all(), + serializer=ModuleBayTypeSerializer, nested=True, - many=True, required=False, + many=True ) _occupied = serializers.BooleanField(required=False, read_only=True) is_module_compatible = serializers.BooleanField(read_only=True) diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 8506c7659..1f093193c 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -13,12 +13,13 @@ from dcim.models import ( InterfaceTemplate, InventoryItemTemplate, ModuleBayTemplate, + ModuleBayType, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) -from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices @@ -405,10 +406,12 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer): allow_null=True, default=None ) - module_bay_types = ModuleBayTypeSerializer( + module_bay_types = SerializedPKRelatedField( + queryset=ModuleBayType.objects.all(), + serializer=ModuleBayTypeSerializer, nested=True, - many=True, required=False, + many=True ) class Meta: diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 228ab6610..f6f293179 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -5,7 +5,7 @@ from rest_framework import serializers from dcim.choices import * from dcim.models import DeviceType, ModuleBayType, ModuleType, ModuleTypeProfile -from netbox.api.fields import AttributesField, ChoiceField +from netbox.api.fields import AttributesField, ChoiceField, SerializedPKRelatedField from netbox.api.serializers import PrimaryModelSerializer from netbox.choices import * @@ -104,10 +104,12 @@ class ModuleTypeSerializer(PrimaryModelSerializer): manufacturer = ManufacturerSerializer( nested=True ) - module_bay_types = ModuleBayTypeSerializer( + module_bay_types = SerializedPKRelatedField( + queryset=ModuleBayType.objects.all(), + serializer=ModuleBayTypeSerializer, nested=True, - many=True, required=False, + many=True ) weight_unit = ChoiceField( choices=WeightUnitChoices, diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 79dc4b6dc..709f278cc 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -298,7 +298,7 @@ class ModuleTypeProfileViewSet(NetBoxModelViewSet): class ModuleTypeViewSet(NetBoxModelViewSet): - queryset = ModuleType.objects.all() + queryset = ModuleType.objects.prefetch_related('module_bay_types__manufacturer') serializer_class = serializers.ModuleTypeSerializer filterset_class = filtersets.ModuleTypeFilterSet @@ -362,7 +362,7 @@ class RearPortTemplateViewSet(NetBoxModelViewSet): class ModuleBayTemplateViewSet(NetBoxModelViewSet): - queryset = ModuleBayTemplate.objects.all() + queryset = ModuleBayTemplate.objects.prefetch_related('module_bay_types__manufacturer') serializer_class = serializers.ModuleBayTemplateSerializer filterset_class = filtersets.ModuleBayTemplateFilterSet @@ -545,7 +545,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class ModuleBayViewSet(NetBoxModelViewSet): queryset = ModuleBay.objects.prefetch_related( - 'module_bay_types', + 'module_bay_types__manufacturer', 'installed_module__module_type__module_bay_types', ) serializer_class = serializers.ModuleBaySerializer diff --git a/netbox/dcim/tests/query_counts.json b/netbox/dcim/tests/query_counts.json index cf47fcc64..0d44f1722 100644 --- a/netbox/dcim/tests/query_counts.json +++ b/netbox/dcim/tests/query_counts.json @@ -50,12 +50,12 @@ "manufacturer:list_objects_with_permission": 17, "module:api_list_objects": 19, "module:list_objects_with_permission": 21, - "modulebay:api_list_objects": 15, + "modulebay:api_list_objects": 16, "modulebay:list_objects_with_permission": 18, - "modulebaytemplate:api_list_objects": 12, + "modulebaytemplate:api_list_objects": 13, "modulebaytype:api_list_objects": 13, "modulebaytype:list_objects_with_permission": 18, - "moduletype:api_list_objects": 14, + "moduletype:api_list_objects": 15, "moduletype:list_objects_with_permission": 19, "moduletypeprofile:api_list_objects": 12, "moduletypeprofile:list_objects_with_permission": 17, diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index f7b666187..f55b8b838 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1510,15 +1510,25 @@ class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase): ) ModuleType.objects.bulk_create(module_types) + module_bay_types = ( + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturers[0], name='Module Bay Type 2', slug='module-bay-type-2'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + for module_type in module_types: + module_type.module_bay_types.set(module_bay_types) + cls.create_data = [ { 'manufacturer': manufacturers[1].pk, 'model': 'Module Type 4', + 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk], }, { 'manufacturer': manufacturers[1].pk, 'model': 'Module Type 5', 'end_of_life': '2035-06-30', + 'module_bay_types': [module_bay_types[0].pk], }, { 'manufacturer': manufacturers[1].pk, @@ -2145,15 +2155,25 @@ class ModuleBayTemplateTestCase(APIViewTestCases.APIViewTestCase): ) ModuleBayTemplate.objects.bulk_create(module_bay_templates) + module_bay_types = ( + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 2', slug='module-bay-type-2'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + for module_bay_template in module_bay_templates: + module_bay_template.module_bay_types.set(module_bay_types) + cls.create_data = [ { 'device_type': devicetype.pk, 'name': 'Module Bay Template 4', 'enabled': False, + 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk], }, { 'device_type': devicetype.pk, 'name': 'Module Bay Template 5', + 'module_bay_types': [module_bay_types[0].pk], }, { 'device_type': devicetype.pk, @@ -4280,15 +4300,25 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase): for module_bay in module_bays: module_bay.save() + module_bay_types = ( + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 1', slug='module-bay-type-1'), + ModuleBayType(manufacturer=manufacturer, name='Module Bay Type 2', slug='module-bay-type-2'), + ) + ModuleBayType.objects.bulk_create(module_bay_types) + for module_bay in module_bays: + module_bay.module_bay_types.set(module_bay_types) + cls.create_data = [ { 'device': device.pk, 'name': 'Device Bay 4', 'enabled': False, + 'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk], }, { 'device': device.pk, 'name': 'Device Bay 5', + 'module_bay_types': [module_bay_types[0].pk], }, { 'device': device.pk, @@ -4342,6 +4372,39 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, 200) self.assertTrue(response.data['is_module_compatible']) + def test_module_bay_types_write(self): + """ + module_bay_types accepts a list of primary keys, renders as nested objects, is cleared by an + empty list, and rejects an unknown primary key without altering the existing assignment. + """ + self.add_permissions('dcim.view_modulebay', 'dcim.change_modulebay') + bay_type = ModuleBayType.objects.first() + module_bay = self._get_queryset().first() + url = self._get_detail_url(module_bay) + self.assertEqual(module_bay.module_bay_types.count(), 2) + + # Assigning by primary key replaces the existing set rather than adding to it + response = self.client.patch(url, {'module_bay_types': [bay_type.pk]}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertListEqual([mbt.pk for mbt in module_bay.module_bay_types.all()], [bay_type.pk]) + + # The response renders nested objects, not bare primary keys + self.assertIsInstance(response.data['module_bay_types'][0], dict) + self.assertEqual(response.data['module_bay_types'][0]['id'], bay_type.pk) + self.assertEqual(response.data['module_bay_types'][0]['name'], bay_type.name) + + # An unknown primary key is rejected without altering the existing assignment + bad_pk = ModuleBayType.objects.order_by('pk').last().pk + 1 + response = self.client.patch(url, {'module_bay_types': [bad_pk]}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('module_bay_types', response.data) + self.assertListEqual([mbt.pk for mbt in module_bay.module_bay_types.all()], [bay_type.pk]) + + # An empty list clears the assignment + response = self.client.patch(url, {'module_bay_types': []}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(module_bay.module_bay_types.count(), 0) + class DeviceBayTestCase(APIViewTestCases.APIViewTestCase): model = DeviceBay