2
0
Эх сурвалжийг харах

Closes #20188: Enable optional component replication when creating a device (#23421)

Add a replicate_components option to the model, REST API, device form,
and CSV import. Keep replication enabled by default and apply the option
only when creating devices.
Jeremy Stretch 5 өдөр өмнө
parent
commit
36ce647478

+ 6 - 2
docs/models/dcim/device.md

@@ -6,7 +6,7 @@ When assigning a multi-U device to a rack, it is considered to be mounted in the
 
 
 A device is said to be full-depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow.
 A device is said to be full-depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow.
 
 
-Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.)
+Each device must be instantiated from a pre-created device type, and by default its components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.)
 
 
 Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed.
 Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed.
 
 
@@ -24,7 +24,7 @@ The functional [device role](./devicerole.md) assigned to this device.
 
 
 ### Device Type
 ### Device Type
 
 
-The hardware [device type](./devicetype.md) which defines the device's make & model. Upon creating, all templated components assigned to the device type will be replicated on the new device.
+The hardware [device type](./devicetype.md) which defines the device's make & model. Upon creating, all templated components assigned to the device type will be replicated on the new device (unless component replication has been disabled; see below).
 
 
 ### Airflow
 ### Airflow
 
 
@@ -42,6 +42,10 @@ The unique physical serial number assigned to this device by its manufacturer.
 
 
 A unique, locally-administered label used to identify hardware resources.
 A unique, locally-administered label used to identify hardware resources.
 
 
+### Replicate Components
+
+Controls whether templated components assigned to the device type are automatically created on the new device (enabled by default). This applies only when creating a new device. Via the REST API, set the write-only `replicate_components` field to `false` to skip component creation.
+
 ### Site
 ### Site
 
 
 The [site](./site.md) in which this device is located.
 The [site](./site.md) in which this device is located.

+ 1 - 1
docs/models/dcim/devicetype.md

@@ -2,7 +2,7 @@
 
 
 A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, network interfaces, and so on).
 A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, network interfaces, and so on).
 
 
-Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively.
+Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. By default, each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively.
 
 
 !!! note
 !!! note
     This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device.
     This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device.

+ 17 - 5
netbox/dcim/api/serializers_/devices.py

@@ -89,6 +89,13 @@ class DeviceSerializer(PrimaryModelSerializer):
     vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
     vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
     config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
     config_context = serializers.SerializerMethodField(read_only=True)
     config_context = serializers.SerializerMethodField(read_only=True)
+    replicate_components = serializers.BooleanField(
+        required=False,
+        default=True,
+        write_only=True,
+        label=_('Replicate components'),
+        help_text=_('Automatically populate components associated with this device type (default: true)')
+    )
 
 
     # Counter fields
     # Counter fields
     console_port_count = serializers.IntegerField(read_only=True)
     console_port_count = serializers.IntegerField(read_only=True)
@@ -109,14 +116,19 @@ class DeviceSerializer(PrimaryModelSerializer):
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
             'status', 'airflow', 'cooling_method', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
             'status', 'airflow', 'cooling_method', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
             'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template',
             'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template',
-            'config_context',
-            'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
-            'console_server_port_count', 'power_port_count', 'power_outlet_count', 'cooling_intake_count',
-            'cooling_outflow_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
-            'module_bay_count', 'inventory_item_count',
+            'config_context', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+            'console_port_count', 'console_server_port_count', 'power_port_count', 'power_outlet_count',
+            'cooling_intake_count', 'cooling_outflow_count', 'interface_count', 'front_port_count', 'rear_port_count',
+            'device_bay_count', 'module_bay_count', 'inventory_item_count', 'replicate_components',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
+    def validate(self, data):
+        # replicate_components applies only when creating a new device
+        if self.instance is not None and not self.nested:
+            data.pop('replicate_components', None)
+        return super().validate(data)
+
     @cached_property
     @cached_property
     def _parent_device_serializer(self):
     def _parent_device_serializer(self):
         return NestedDeviceSerializer(context={'request': self.context['request']})
         return NestedDeviceSerializer(context={'request': self.context['request']})

+ 16 - 1
netbox/dcim/forms/bulk_import.py

@@ -755,13 +755,18 @@ class DeviceImportForm(BaseDeviceImportForm):
         required=False,
         required=False,
         help_text=_('Config template')
         help_text=_('Config template')
     )
     )
+    replicate_components = forms.BooleanField(
+        label=_('Replicate components'),
+        required=False,
+        help_text=_('Automatically populate components associated with this device type (enabled by default)')
+    )
 
 
     class Meta(BaseDeviceImportForm.Meta):
     class Meta(BaseDeviceImportForm.Meta):
         fields = [
         fields = [
             'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
             'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
             'cooling_method', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description',
             'cooling_method', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description',
-            'config_template', 'owner', 'comments', 'tags',
+            'config_template', 'owner', 'comments', 'replicate_components', 'tags',
         ]
         ]
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
@@ -807,6 +812,16 @@ class DeviceImportForm(BaseDeviceImportForm):
         if device_bay := self.cleaned_data.get('device_bay'):
         if device_bay := self.cleaned_data.get('device_bay'):
             self.instance.parent_bay = device_bay
             self.instance.parent_bay = device_bay
 
 
+        # Disable component replication for a new device if requested
+        if not self.instance.pk and not self.cleaned_data.get('replicate_components'):
+            self.instance._disable_replication = True
+
+    def clean_replicate_components(self):
+        # Default to True when replicate_components is omitted or left blank in the uploaded data
+        if self.data.get('replicate_components') in (None, ''):
+            return True
+        return self.cleaned_data['replicate_components']
+
 
 
 class ModuleImportForm(ModuleCommonForm, PrimaryModelImportForm):
 class ModuleImportForm(ModuleCommonForm, PrimaryModelImportForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(

+ 17 - 1
netbox/dcim/forms/model_forms.py

@@ -884,6 +884,12 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
         queryset=ConfigTemplate.objects.all(),
         queryset=ConfigTemplate.objects.all(),
         required=False
         required=False
     )
     )
+    replicate_components = forms.BooleanField(
+        label=_('Replicate components'),
+        required=False,
+        initial=True,
+        help_text=_("Automatically populate components associated with this device type")
+    )
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
@@ -892,7 +898,7 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
             'latitude', 'longitude', 'status', 'airflow', 'cooling_method', 'platform', 'primary_ip4', 'primary_ip6',
             'latitude', 'longitude', 'status', 'airflow', 'cooling_method', 'platform', 'primary_ip4', 'primary_ip6',
             'oob_ip', 'cluster',
             'oob_ip', 'cluster',
             'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
             'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
-            'owner', 'comments', 'tags', 'local_context_data',
+            'owner', 'comments', 'tags', 'local_context_data', 'replicate_components',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -900,6 +906,9 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
 
 
         if self.instance.pk:
         if self.instance.pk:
 
 
+            # Component replication applies only to new devices
+            del self.fields['replicate_components']
+
             # Compile list of choices for primary IPv4 and IPv6 addresses
             # Compile list of choices for primary IPv4 and IPv6 addresses
             oob_ip_choices = [(None, '---------')]
             oob_ip_choices = [(None, '---------')]
             for family in [4, 6]:
             for family in [4, 6]:
@@ -956,6 +965,13 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
         if position:
         if position:
             self.fields['position'].widget.choices = [(position, f'U{position}')]
             self.fields['position'].widget.choices = [(position, f'U{position}')]
 
 
+    def clean(self):
+        super().clean()
+
+        # Disable component replication for a new device if requested
+        if not self.instance.pk and not self.cleaned_data.get('replicate_components'):
+            self.instance._disable_replication = True
+
 
 
 class ModuleForm(ModuleCommonForm, PrimaryModelForm):
 class ModuleForm(ModuleCommonForm, PrimaryModelForm):
     status = ChoiceField(
     status = ChoiceField(

+ 8 - 2
netbox/dcim/models/devices.py

@@ -829,6 +829,12 @@ class Device(
             ('render_config', 'Render configuration'),
             ('render_config', 'Render configuration'),
         ]
         ]
 
 
+    def __init__(self, *args, replicate_components=True, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Controls whether components are instantiated from the DeviceType when a new Device is saved
+        self._disable_replication = not replicate_components
+
     def __str__(self):
     def __str__(self):
         if self.label and self.asset_tag:
         if self.label and self.asset_tag:
             return f'{self.label} ({self.asset_tag})'
             return f'{self.label} ({self.asset_tag})'
@@ -1113,8 +1119,8 @@ class Device(
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
-        # If this is a new Device, instantiate all the related components per the DeviceType definition
-        if is_new:
+        # Instantiate components from the device type
+        if is_new and not self._disable_replication:
             self._instantiate_components(self.device_type.consoleporttemplates.all())
             self._instantiate_components(self.device_type.consoleporttemplates.all())
             self._instantiate_components(self.device_type.consoleserverporttemplates.all())
             self._instantiate_components(self.device_type.consoleserverporttemplates.all())
             self._instantiate_components(self.device_type.powerporttemplates.all())
             self._instantiate_components(self.device_type.powerporttemplates.all())

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

@@ -2976,6 +2976,52 @@ class DeviceTestCase(Mixins.FieldPrefetchMixin, APIViewTestCases.APIViewTestCase
 
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
+    def test_create_device_replicates_components(self):
+        """
+        Creating a device without specifying replicate_components should instantiate its device type's components.
+        """
+        self.add_permissions('dcim.add_device')
+        device_type = DeviceType.objects.get(slug='device-type-1')
+        InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
+        data = {
+            'device_type': device_type.pk,
+            'role': DeviceRole.objects.first().pk,
+            'site': Site.objects.first().pk,
+            'name': 'Replication Test Device',
+        }
+
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertNotIn('replicate_components', response.data)
+        device = Device.objects.get(pk=response.data['id'])
+        self.assertTrue(device.interfaces.filter(name='eth0').exists())
+
+    def test_create_device_without_replicating_components(self):
+        """
+        Creating a device with replicate_components=False should skip instantiation of its device type's components.
+        """
+        self.add_permissions('dcim.add_device', 'extras.view_tag')
+        device_type = DeviceType.objects.get(slug='device-type-1')
+        InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
+        tag = Tag.objects.create(name='Tag 1', slug='tag-1')
+        data = [
+            {
+                'device_type': device_type.pk,
+                'role': DeviceRole.objects.first().pk,
+                'site': Site.objects.first().pk,
+                'name': f'No Replication Test Device {i}',
+                'tags': [{'name': tag.name}],
+                'replicate_components': False,
+            }
+            for i in range(1, 3)
+        ]
+
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        for device in Device.objects.filter(pk__in=[d['id'] for d in response.data]):
+            self.assertFalse(device.interfaces.exists())
+            self.assertEqual(list(device.tags.all()), [tag])
+
     def test_list_objects_reuse_parent_device_serializers(self):
     def test_list_objects_reuse_parent_device_serializers(self):
         """Installed child devices share one nested parent serializer pair per request."""
         """Installed child devices share one nested parent serializer pair per request."""
         self.add_permissions('dcim.view_device')
         self.add_permissions('dcim.view_device')

+ 51 - 0
netbox/dcim/tests/test_forms.py

@@ -118,6 +118,54 @@ class DeviceTestCase(TestCase):
         self.assertTrue(form.is_valid())
         self.assertTrue(form.is_valid())
         self.assertTrue(form.save())
         self.assertTrue(form.save())
 
 
+    def test_replicate_components(self):
+        device_type = DeviceType.objects.first()
+        InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
+        data = {
+            'role': DeviceRole.objects.first().pk,
+            'device_type': device_type.pk,
+            'site': Site.objects.first().pk,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
+        }
+
+        form = DeviceForm(data={**data, 'name': 'Device 2', 'replicate_components': True})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertTrue(form.save().interfaces.filter(name='eth0').exists())
+
+        form = DeviceForm(data={**data, 'name': 'Device 3', 'replicate_components': False})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertFalse(form.save().interfaces.exists())
+
+        # The replicate_components field is not available when editing an existing device
+        form = DeviceForm(instance=Device.objects.get(name='Device 1'))
+        self.assertNotIn('replicate_components', form.fields)
+
+    def test_import_replicate_components(self):
+        device_type = DeviceType.objects.first()
+        InterfaceTemplate.objects.create(device_type=device_type, name='eth0', type='1000base-t')
+        data = {
+            'role': DeviceRole.objects.first().name,
+            'manufacturer': device_type.manufacturer.name,
+            'device_type': device_type.model,
+            'site': Site.objects.first().name,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
+        }
+
+        # Components are replicated by default
+        form = DeviceImportForm(data={**data, 'name': 'Device 2'})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertTrue(form.save().interfaces.filter(name='eth0').exists())
+
+        # A blank value (e.g. an empty CSV cell) is treated as omitted
+        for i, value in enumerate(('', None), start=4):
+            form = DeviceImportForm(data={**data, 'name': f'Device {i}', 'replicate_components': value})
+            self.assertTrue(form.is_valid(), form.errors)
+            self.assertTrue(form.save().interfaces.filter(name='eth0').exists())
+
+        form = DeviceImportForm(data={**data, 'name': 'Device 3', 'replicate_components': 'false'})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertFalse(form.save().interfaces.exists())
+
     def test_racked_device_occupied(self):
     def test_racked_device_occupied(self):
         form = DeviceForm(data={
         form = DeviceForm(data={
             'name': 'test',
             'name': 'test',
@@ -669,6 +717,7 @@ class VCPositionTokenFormTestCase(TestCase):
             'position': None,
             'position': None,
             'platform': None,
             'platform': None,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
+            'replicate_components': True,
             'virtual_chassis': VirtualChassis.objects.first().pk,
             'virtual_chassis': VirtualChassis.objects.first().pk,
             'vc_position': 2,
             'vc_position': 2,
         })
         })
@@ -689,6 +738,7 @@ class VCPositionTokenFormTestCase(TestCase):
             'position': None,
             'position': None,
             'platform': None,
             'platform': None,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
+            'replicate_components': True,
         })
         })
         self.assertTrue(form.is_valid())
         self.assertTrue(form.is_valid())
         device = form.save()
         device = form.save()
@@ -715,6 +765,7 @@ class VCPositionTokenFormTestCase(TestCase):
             'position': None,
             'position': None,
             'platform': None,
             'platform': None,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
+            'replicate_components': True,
         })
         })
         self.assertTrue(form.is_valid())
         self.assertTrue(form.is_valid())
         with self.assertRaises(AbortRequest):
         with self.assertRaises(AbortRequest):

+ 24 - 0
netbox/dcim/tests/test_models.py

@@ -972,6 +972,30 @@ class DeviceTestCase(TestCase):
         )
         )
         self.assertEqual(inventoryitem.cf['cf1'], 'foo')
         self.assertEqual(inventoryitem.cf['cf1'], 'foo')
 
 
+    def test_device_creation_without_replication(self):
+        """
+        Ensure that no components are created when component replication has been disabled.
+        """
+        device = Device(
+            site=Site.objects.first(),
+            device_type=DeviceType.objects.first(),
+            role=DeviceRole.objects.first(),
+            name='Test Device 1',
+            replicate_components=False
+        )
+        device.save()
+
+        self.assertFalse(ConsolePort.objects.filter(device=device).exists())
+        self.assertFalse(ConsoleServerPort.objects.filter(device=device).exists())
+        self.assertFalse(PowerPort.objects.filter(device=device).exists())
+        self.assertFalse(PowerOutlet.objects.filter(device=device).exists())
+        self.assertFalse(Interface.objects.filter(device=device).exists())
+        self.assertFalse(RearPort.objects.filter(device=device).exists())
+        self.assertFalse(FrontPort.objects.filter(device=device).exists())
+        self.assertFalse(ModuleBay.objects.filter(device=device).exists())
+        self.assertFalse(DeviceBay.objects.filter(device=device).exists())
+        self.assertFalse(InventoryItem.objects.filter(device=device).exists())
+
     def test_multiple_unnamed_devices(self):
     def test_multiple_unnamed_devices(self):
 
 
         device1 = Device(
         device1 = Device(

+ 34 - 0
netbox/dcim/tests/test_views.py

@@ -3039,6 +3039,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'comments': 'A new device',
             'comments': 'A new device',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
             'local_context_data': None,
             'local_context_data': None,
+            'replicate_components': True,
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -3394,6 +3395,39 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             response.content.decode('utf-8')
             response.content.decode('utf-8')
         )
         )
 
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_create_device_retry_after_failed_replication(self):
+        """
+        The replicate_components field should still be rendered after component replication fails, so that
+        resubmitting the form does not silently disable replication.
+        """
+        self.add_permissions('dcim.add_device')
+        device_type = DeviceType.objects.get(pk=self.form_data['device_type'])
+
+        # Create two interface templates whose names conflict once {vc_position} has been resolved
+        InterfaceTemplate.objects.create(device_type=device_type, name='eth{vc_position}', type='1000base-t')
+        conflicting_template = InterfaceTemplate.objects.create(
+            device_type=device_type, name='eth0', type='1000base-t'
+        )
+
+        # Component replication raises AbortRequest, rolling back creation of the device
+        response = self.client.post(self._get_url('add'), post_data(self.form_data))
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'Component name conflict')
+        self.assertFalse(Device.objects.filter(name=self.form_data['name']).exists())
+        self.assertInHTML(
+            '<input type="checkbox" name="replicate_components" class="form-check-input" '
+            'id="id_replicate_components" aria-describedby="id_replicate_components_helptext" checked>',
+            response.content.decode('utf-8')
+        )
+
+        # Resolve the conflict and resubmit the form
+        conflicting_template.delete()
+        response = self.client.post(self._get_url('add'), post_data(self.form_data))
+        self.assertHttpStatus(response, 302)
+        device = Device.objects.get(name=self.form_data['name'])
+        self.assertTrue(device.interfaces.filter(name='eth0').exists())
+
 
 
 class ModuleTestCase(
 class ModuleTestCase(
     # Module does not support bulk renaming (no name field) or
     # Module does not support bulk renaming (no name field) or

+ 3 - 0
netbox/templates/dcim/device_edit.html

@@ -24,6 +24,9 @@
       {% render_field form.device_type %}
       {% render_field form.device_type %}
       {% render_field form.serial %}
       {% render_field form.serial %}
       {% render_field form.asset_tag %}
       {% render_field form.asset_tag %}
+      {% if form.replicate_components %}
+        {% render_field form.replicate_components %}
+      {% endif %}
     </div>
     </div>
 
 
     <div class="field-group my-5">
     <div class="field-group my-5">