diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 92f74036a..324b90609 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -655,7 +655,6 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
class ModuleForm(NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
- required=False,
initial_params={
'modulebays': '$module_bay'
}
@@ -670,7 +669,7 @@ class ModuleForm(NetBoxModelForm):
queryset=Manufacturer.objects.all(),
required=False,
initial_params={
- 'device_types': '$device_type'
+ 'module_types': '$module_type'
}
)
module_type = DynamicModelChoiceField(
@@ -684,13 +683,34 @@ class ModuleForm(NetBoxModelForm):
queryset=Tag.objects.all(),
required=False
)
+ replicate_components = forms.BooleanField(
+ required=False,
+ initial=True,
+ help_text="Automatically populate components associated with this module type"
+ )
class Meta:
model = Module
fields = [
- 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', 'comments',
+ 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
+ 'replicate_components', 'comments',
]
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ if self.instance.pk:
+ self.fields['replicate_components'].initial = False
+ self.fields['replicate_components'].disabled = True
+
+ def save(self, *args, **kwargs):
+
+ # If replicate_components is False, disable automatic component replication on the instance
+ if self.instance.pk or not self.cleaned_data['replicate_components']:
+ self.instance._disable_replication = True
+
+ return super().save(*args, **kwargs)
+
class CableForm(TenancyForm, NetBoxModelForm):
tags = DynamicModelMultipleChoiceField(
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 73bb621fc..878c11099 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -8,6 +8,7 @@ from utilities.forms import (
)
__all__ = (
+ 'ComponentTemplateCreateForm',
'DeviceComponentCreateForm',
'DeviceTypeComponentCreateForm',
'FrontPortCreateForm',
@@ -51,6 +52,18 @@ class DeviceTypeComponentCreateForm(ComponentCreateForm):
field_order = ('device_type', 'name_pattern', 'label_pattern')
+class ComponentTemplateCreateForm(ComponentCreateForm):
+ device_type = DynamicModelChoiceField(
+ queryset=DeviceType.objects.all(),
+ required=False
+ )
+ module_type = DynamicModelChoiceField(
+ queryset=ModuleType.objects.all(),
+ required=False
+ )
+ field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern')
+
+
class DeviceComponentCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 37c900286..f721f90c2 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1054,12 +1054,13 @@ class Module(NetBoxModel, ConfigContextModel):
return reverse('dcim:module', args=[self.pk])
def save(self, *args, **kwargs):
- is_new = not bool(self.pk)
+ is_new = self.pk is None
super().save(*args, **kwargs)
- # If this is a new Module, instantiate all its related components per the ModuleType definition
- if is_new:
+ # If this is a new Module and component replication has not been disabled, instantiate all its
+ # related components per the ModuleType definition
+ if is_new and not getattr(self, '_disable_replication', False):
ConsolePort.objects.bulk_create(
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
)
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index b531a3ca7..46edb20dc 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -326,11 +326,11 @@ DEVICEBAY_BUTTONS = """
{% if perms.dcim.change_devicebay %}
{% if record.installed_device %}
-
+
{% else %}
-
+
{% endif %}
{% endif %}
@@ -340,11 +340,11 @@ MODULEBAY_BUTTONS = """
{% if perms.dcim.add_module %}
{% if record.installed_module %}
-
+
{% else %}
-
+
{% endif %}
{% endif %}
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 4afa8a9f4..70eb4b659 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -13,7 +13,7 @@ from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from tenancy.models import Tenant
-from utilities.testing import ViewTestCases, create_tags, create_test_device
+from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@@ -1770,6 +1770,7 @@ class ModuleTestCase(
# bulk creation (need to specify module bays)
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
+ ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
@@ -1799,9 +1800,11 @@ class ModuleTestCase(
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[0], name='Module Bay 2'),
ModuleBay(device=devices[0], name='Module Bay 3'),
+ ModuleBay(device=devices[0], name='Module Bay 4'),
ModuleBay(device=devices[1], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[1], name='Module Bay 3'),
+ ModuleBay(device=devices[1], name='Module Bay 4'),
)
ModuleBay.objects.bulk_create(module_bays)
@@ -1833,6 +1836,39 @@ class ModuleTestCase(
"Device 2,Module Bay 3,Module Type 3,C,C",
)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_module_component_replication(self):
+ self.add_permissions('dcim.add_module')
+
+ # Add 5 InterfaceTemplates to a ModuleType
+ module_type = ModuleType.objects.first()
+ interface_templates = [
+ InterfaceTemplate(module_type=module_type, name=f'Interface {i}') for i in range(1, 6)
+ ]
+ InterfaceTemplate.objects.bulk_create(interface_templates)
+
+ form_data = self.form_data.copy()
+ device = Device.objects.get(pk=form_data['device'])
+
+ # Create a module *without* replicating components
+ form_data['replicate_components'] = False
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertEqual(Interface.objects.filter(device=device).count(), 0)
+
+ # Create a second module (in the next bay) with replicated components
+ form_data['module_bay'] += 1
+ form_data['replicate_components'] = True
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertEqual(Interface.objects.filter(device=device).count(), 5)
+
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 231a3ef09..c70f33074 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1063,7 +1063,7 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsolePortTemplate.objects.all()
- form = forms.DeviceTypeComponentCreateForm
+ form = forms.ComponentTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
@@ -1097,7 +1097,7 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPortTemplate.objects.all()
- form = forms.DeviceTypeComponentCreateForm
+ form = forms.ComponentTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
@@ -1131,7 +1131,7 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerPortTemplateCreateView(generic.ComponentCreateView):
queryset = PowerPortTemplate.objects.all()
- form = forms.DeviceTypeComponentCreateForm
+ form = forms.ComponentTemplateCreateForm
model_form = forms.PowerPortTemplateForm
@@ -1165,7 +1165,7 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
queryset = PowerOutletTemplate.objects.all()
- form = forms.DeviceTypeComponentCreateForm
+ form = forms.ComponentTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
@@ -1199,7 +1199,7 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
class InterfaceTemplateCreateView(generic.ComponentCreateView):
queryset = InterfaceTemplate.objects.all()
- form = forms.DeviceTypeComponentCreateForm
+ form = forms.ComponentTemplateCreateForm
model_form = forms.InterfaceTemplateForm
@@ -1275,7 +1275,7 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
class RearPortTemplateCreateView(generic.ComponentCreateView):
queryset = RearPortTemplate.objects.all()
- form = forms.DeviceTypeComponentCreateForm
+ form = forms.ComponentTemplateCreateForm
model_form = forms.RearPortTemplateForm
@@ -1377,7 +1377,7 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
class InventoryItemTemplateCreateView(generic.ComponentCreateView):
queryset = InventoryItemTemplate.objects.all()
- form = forms.DeviceTypeComponentCreateForm
+ form = forms.ComponentTemplateCreateForm
model_form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitem_create.html'