diff --git a/netbox/dcim/api/serializers_/devices.py b/netbox/dcim/api/serializers_/devices.py index 110fce97e..ee543df24 100644 --- a/netbox/dcim/api/serializers_/devices.py +++ b/netbox/dcim/api/serializers_/devices.py @@ -203,6 +203,7 @@ class ModuleSerializer(PrimaryModelSerializer): module_type = data.get('module_type') module_bay = data.get('module_bay') + # Required-field validation fires separately; skip here if any are missing. if not all([device, module_type, module_bay]): return data @@ -238,8 +239,7 @@ class ModuleSerializer(PrimaryModelSerializer): if template.name.count(MODULE_TOKEN) != len(module_bays): raise serializers.ValidationError( _( - "Cannot install module with placeholder values in a module bay tree {level} in tree " - "but {tokens} placeholders given." + "Cannot install module with {tokens} placeholder(s) in a module bay at depth {level}." ).format( level=len(module_bays), tokens=template.name.count(MODULE_TOKEN) ) @@ -274,8 +274,8 @@ class ModuleSerializer(PrimaryModelSerializer): # Tags are handled after save; pop them here to pass to _save_tags() tags = validated_data.pop('tags', None) - # Build the instance without saving so we can set private attributes - # that control component replication behaviour in Module.save() + # _adopt_components and _disable_replication must be set on the instance before + # save() is called, so we cannot delegate to super().create() here. instance = self.Meta.model(**validated_data) if adopt_components: instance._adopt_components = True diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 3128b6fc0..65b14bd6c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1846,9 +1846,42 @@ class ModuleTest(APIViewTestCases.APIViewTestCase): } response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['serial'], 'PATCHED') # No interfaces should have been created by the PATCH self.assertFalse(device.interfaces.exists()) + def test_adopt_and_replicate_components(self): + """ + Installing a module with both adopt_components=True and replicate_components=True + should adopt existing unowned components and create new components for templates + that have no matching existing component. + """ + self.add_permissions('dcim.add_module') + manufacturer = Manufacturer.objects.get(name='Generic') + device = create_test_device('Device for Adopt+Replicate Test') + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt+Replicate Test Module Type') + InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t') + InterfaceTemplate.objects.create(module_type=module_type, name='eth1', type='1000base-t') + module_bay = ModuleBay.objects.create(device=device, name='Adopt+Replicate Bay') + # eth0 already exists (unowned); eth1 does not + existing_iface = Interface.objects.create(device=device, name='eth0', type='1000base-t') + + url = reverse('dcim-api:module-list') + data = { + 'device': device.pk, + 'module_bay': module_bay.pk, + 'module_type': module_type.pk, + 'adopt_components': True, + 'replicate_components': True, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + # eth0 should have been adopted (now owned by the new module) + existing_iface.refresh_from_db() + self.assertIsNotNone(existing_iface.module) + # eth1 should have been created + self.assertTrue(device.interfaces.filter(name='eth1').exists()) + class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort