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