فهرست منبع

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
Martin Hauser 2 روز پیش
والد
کامیت
875e7d0885

+ 5 - 2
netbox/dcim/api/serializers_/device_components.py

@@ -15,6 +15,7 @@ from dcim.models import (
     Interface,
     Interface,
     InventoryItem,
     InventoryItem,
     ModuleBay,
     ModuleBay,
+    ModuleBayType,
     PortMapping,
     PortMapping,
     PowerOutlet,
     PowerOutlet,
     PowerPort,
     PowerPort,
@@ -530,10 +531,12 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
         required=False,
         required=False,
         allow_null=True
         allow_null=True
     )
     )
-    module_bay_types = ModuleBayTypeSerializer(
+    module_bay_types = SerializedPKRelatedField(
+        queryset=ModuleBayType.objects.all(),
+        serializer=ModuleBayTypeSerializer,
         nested=True,
         nested=True,
-        many=True,
         required=False,
         required=False,
+        many=True
     )
     )
     _occupied = serializers.BooleanField(required=False, read_only=True)
     _occupied = serializers.BooleanField(required=False, read_only=True)
     is_module_compatible = serializers.BooleanField(read_only=True)
     is_module_compatible = serializers.BooleanField(read_only=True)

+ 6 - 3
netbox/dcim/api/serializers_/devicetype_components.py

@@ -13,12 +13,13 @@ from dcim.models import (
     InterfaceTemplate,
     InterfaceTemplate,
     InventoryItemTemplate,
     InventoryItemTemplate,
     ModuleBayTemplate,
     ModuleBayTemplate,
+    ModuleBayType,
     PortTemplateMapping,
     PortTemplateMapping,
     PowerOutletTemplate,
     PowerOutletTemplate,
     PowerPortTemplate,
     PowerPortTemplate,
     RearPortTemplate,
     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.gfk_fields import GFKSerializerField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices
 from netbox.choices import DiameterUnitChoices, FlowRateUnitChoices
@@ -405,10 +406,12 @@ class ModuleBayTemplateSerializer(ComponentTemplateSerializer):
         allow_null=True,
         allow_null=True,
         default=None
         default=None
     )
     )
-    module_bay_types = ModuleBayTypeSerializer(
+    module_bay_types = SerializedPKRelatedField(
+        queryset=ModuleBayType.objects.all(),
+        serializer=ModuleBayTypeSerializer,
         nested=True,
         nested=True,
-        many=True,
         required=False,
         required=False,
+        many=True
     )
     )
 
 
     class Meta:
     class Meta:

+ 5 - 3
netbox/dcim/api/serializers_/devicetypes.py

@@ -5,7 +5,7 @@ from rest_framework import serializers
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.models import DeviceType, ModuleBayType, ModuleType, ModuleTypeProfile
 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.api.serializers import PrimaryModelSerializer
 from netbox.choices import *
 from netbox.choices import *
 
 
@@ -104,10 +104,12 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
     manufacturer = ManufacturerSerializer(
     manufacturer = ManufacturerSerializer(
         nested=True
         nested=True
     )
     )
-    module_bay_types = ModuleBayTypeSerializer(
+    module_bay_types = SerializedPKRelatedField(
+        queryset=ModuleBayType.objects.all(),
+        serializer=ModuleBayTypeSerializer,
         nested=True,
         nested=True,
-        many=True,
         required=False,
         required=False,
+        many=True
     )
     )
     weight_unit = ChoiceField(
     weight_unit = ChoiceField(
         choices=WeightUnitChoices,
         choices=WeightUnitChoices,

+ 3 - 3
netbox/dcim/api/views.py

@@ -298,7 +298,7 @@ class ModuleTypeProfileViewSet(NetBoxModelViewSet):
 
 
 
 
 class ModuleTypeViewSet(NetBoxModelViewSet):
 class ModuleTypeViewSet(NetBoxModelViewSet):
-    queryset = ModuleType.objects.all()
+    queryset = ModuleType.objects.prefetch_related('module_bay_types__manufacturer')
     serializer_class = serializers.ModuleTypeSerializer
     serializer_class = serializers.ModuleTypeSerializer
     filterset_class = filtersets.ModuleTypeFilterSet
     filterset_class = filtersets.ModuleTypeFilterSet
 
 
@@ -362,7 +362,7 @@ class RearPortTemplateViewSet(NetBoxModelViewSet):
 
 
 
 
 class ModuleBayTemplateViewSet(NetBoxModelViewSet):
 class ModuleBayTemplateViewSet(NetBoxModelViewSet):
-    queryset = ModuleBayTemplate.objects.all()
+    queryset = ModuleBayTemplate.objects.prefetch_related('module_bay_types__manufacturer')
     serializer_class = serializers.ModuleBayTemplateSerializer
     serializer_class = serializers.ModuleBayTemplateSerializer
     filterset_class = filtersets.ModuleBayTemplateFilterSet
     filterset_class = filtersets.ModuleBayTemplateFilterSet
 
 
@@ -545,7 +545,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 
 
 class ModuleBayViewSet(NetBoxModelViewSet):
 class ModuleBayViewSet(NetBoxModelViewSet):
     queryset = ModuleBay.objects.prefetch_related(
     queryset = ModuleBay.objects.prefetch_related(
-        'module_bay_types',
+        'module_bay_types__manufacturer',
         'installed_module__module_type__module_bay_types',
         'installed_module__module_type__module_bay_types',
     )
     )
     serializer_class = serializers.ModuleBaySerializer
     serializer_class = serializers.ModuleBaySerializer

+ 3 - 3
netbox/dcim/tests/query_counts.json

@@ -50,12 +50,12 @@
   "manufacturer:list_objects_with_permission": 17,
   "manufacturer:list_objects_with_permission": 17,
   "module:api_list_objects": 19,
   "module:api_list_objects": 19,
   "module:list_objects_with_permission": 21,
   "module:list_objects_with_permission": 21,
-  "modulebay:api_list_objects": 15,
+  "modulebay:api_list_objects": 16,
   "modulebay:list_objects_with_permission": 18,
   "modulebay:list_objects_with_permission": 18,
-  "modulebaytemplate:api_list_objects": 12,
+  "modulebaytemplate:api_list_objects": 13,
   "modulebaytype:api_list_objects": 13,
   "modulebaytype:api_list_objects": 13,
   "modulebaytype:list_objects_with_permission": 18,
   "modulebaytype:list_objects_with_permission": 18,
-  "moduletype:api_list_objects": 14,
+  "moduletype:api_list_objects": 15,
   "moduletype:list_objects_with_permission": 19,
   "moduletype:list_objects_with_permission": 19,
   "moduletypeprofile:api_list_objects": 12,
   "moduletypeprofile:api_list_objects": 12,
   "moduletypeprofile:list_objects_with_permission": 17,
   "moduletypeprofile:list_objects_with_permission": 17,

+ 63 - 0
netbox/dcim/tests/test_api.py

@@ -1510,15 +1510,25 @@ class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase):
         )
         )
         ModuleType.objects.bulk_create(module_types)
         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 = [
         cls.create_data = [
             {
             {
                 'manufacturer': manufacturers[1].pk,
                 'manufacturer': manufacturers[1].pk,
                 'model': 'Module Type 4',
                 'model': 'Module Type 4',
+                'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk],
             },
             },
             {
             {
                 'manufacturer': manufacturers[1].pk,
                 'manufacturer': manufacturers[1].pk,
                 'model': 'Module Type 5',
                 'model': 'Module Type 5',
                 'end_of_life': '2035-06-30',
                 'end_of_life': '2035-06-30',
+                'module_bay_types': [module_bay_types[0].pk],
             },
             },
             {
             {
                 'manufacturer': manufacturers[1].pk,
                 'manufacturer': manufacturers[1].pk,
@@ -2145,15 +2155,25 @@ class ModuleBayTemplateTestCase(APIViewTestCases.APIViewTestCase):
         )
         )
         ModuleBayTemplate.objects.bulk_create(module_bay_templates)
         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 = [
         cls.create_data = [
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Module Bay Template 4',
                 'name': 'Module Bay Template 4',
                 'enabled': False,
                 'enabled': False,
+                'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk],
             },
             },
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Module Bay Template 5',
                 'name': 'Module Bay Template 5',
+                'module_bay_types': [module_bay_types[0].pk],
             },
             },
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
@@ -4280,15 +4300,25 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase):
         for module_bay in module_bays:
         for module_bay in module_bays:
             module_bay.save()
             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 = [
         cls.create_data = [
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Device Bay 4',
                 'name': 'Device Bay 4',
                 'enabled': False,
                 'enabled': False,
+                'module_bay_types': [module_bay_types[0].pk, module_bay_types[1].pk],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Device Bay 5',
                 'name': 'Device Bay 5',
+                'module_bay_types': [module_bay_types[0].pk],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
@@ -4342,6 +4372,39 @@ class ModuleBayTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
         self.assertTrue(response.data['is_module_compatible'])
         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):
 class DeviceBayTestCase(APIViewTestCases.APIViewTestCase):
     model = DeviceBay
     model = DeviceBay