From a2981870ce6911d577dc2af3d6cd2cf5c952aa14 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 4 Feb 2022 11:51:30 -0500 Subject: [PATCH] #7844: Allow installing modules via UI without replicating components --- netbox/dcim/forms/models.py | 26 +++++++++++++++++--- netbox/dcim/forms/object_create.py | 13 ++++++++++ netbox/dcim/models/devices.py | 7 +++--- netbox/dcim/tables/template_code.py | 8 +++--- netbox/dcim/tests/test_views.py | 38 ++++++++++++++++++++++++++++- netbox/dcim/views.py | 14 +++++------ 6 files changed, 88 insertions(+), 18 deletions(-) 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'