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
This commit is contained in:
Martin Hauser 2026-08-19 22:35:01 +02:00 committed by GitHub
parent cff012ac9c
commit 875e7d0885
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 85 additions and 14 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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