mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
#7844: Allow installing modules via UI without replicating components
This commit is contained in:
@ -655,7 +655,6 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
class ModuleForm(NetBoxModelForm):
|
class ModuleForm(NetBoxModelForm):
|
||||||
device = DynamicModelChoiceField(
|
device = DynamicModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
|
||||||
initial_params={
|
initial_params={
|
||||||
'modulebays': '$module_bay'
|
'modulebays': '$module_bay'
|
||||||
}
|
}
|
||||||
@ -670,7 +669,7 @@ class ModuleForm(NetBoxModelForm):
|
|||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
initial_params={
|
initial_params={
|
||||||
'device_types': '$device_type'
|
'module_types': '$module_type'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
module_type = DynamicModelChoiceField(
|
module_type = DynamicModelChoiceField(
|
||||||
@ -684,13 +683,34 @@ class ModuleForm(NetBoxModelForm):
|
|||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
replicate_components = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
help_text="Automatically populate components associated with this module type"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Module
|
model = Module
|
||||||
fields = [
|
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):
|
class CableForm(TenancyForm, NetBoxModelForm):
|
||||||
tags = DynamicModelMultipleChoiceField(
|
tags = DynamicModelMultipleChoiceField(
|
||||||
|
@ -8,6 +8,7 @@ from utilities.forms import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'ComponentTemplateCreateForm',
|
||||||
'DeviceComponentCreateForm',
|
'DeviceComponentCreateForm',
|
||||||
'DeviceTypeComponentCreateForm',
|
'DeviceTypeComponentCreateForm',
|
||||||
'FrontPortCreateForm',
|
'FrontPortCreateForm',
|
||||||
@ -51,6 +52,18 @@ class DeviceTypeComponentCreateForm(ComponentCreateForm):
|
|||||||
field_order = ('device_type', 'name_pattern', 'label_pattern')
|
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):
|
class DeviceComponentCreateForm(ComponentCreateForm):
|
||||||
device = DynamicModelChoiceField(
|
device = DynamicModelChoiceField(
|
||||||
queryset=Device.objects.all()
|
queryset=Device.objects.all()
|
||||||
|
@ -1054,12 +1054,13 @@ class Module(NetBoxModel, ConfigContextModel):
|
|||||||
return reverse('dcim:module', args=[self.pk])
|
return reverse('dcim:module', args=[self.pk])
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
is_new = not bool(self.pk)
|
is_new = self.pk is None
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# If this is a new Module, instantiate all its related components per the ModuleType definition
|
# If this is a new Module and component replication has not been disabled, instantiate all its
|
||||||
if is_new:
|
# related components per the ModuleType definition
|
||||||
|
if is_new and not getattr(self, '_disable_replication', False):
|
||||||
ConsolePort.objects.bulk_create(
|
ConsolePort.objects.bulk_create(
|
||||||
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
|
[x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
|
||||||
)
|
)
|
||||||
|
@ -326,11 +326,11 @@ DEVICEBAY_BUTTONS = """
|
|||||||
{% if perms.dcim.change_devicebay %}
|
{% if perms.dcim.change_devicebay %}
|
||||||
{% if record.installed_device %}
|
{% if record.installed_device %}
|
||||||
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-minus-thick" aria-hidden="true" title="Remove device"></i>
|
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove device"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||||
<i class="mdi mdi-plus-thick" aria-hidden="true" title="Install device"></i>
|
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install device"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -340,11 +340,11 @@ MODULEBAY_BUTTONS = """
|
|||||||
{% if perms.dcim.add_module %}
|
{% if perms.dcim.add_module %}
|
||||||
{% if record.installed_module %}
|
{% if record.installed_module %}
|
||||||
<a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
|
<a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
|
||||||
<i class="mdi mdi-minus-thick" aria-hidden="true" title="Remove module"></i>
|
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||||
<i class="mdi mdi-plus-thick" aria-hidden="true" title="Install module"></i>
|
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -13,7 +13,7 @@ from dcim.constants import *
|
|||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from ipam.models import ASN, RIR, VLAN, VRF
|
from ipam.models import ASN, RIR, VLAN, VRF
|
||||||
from tenancy.models import Tenant
|
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
|
from wireless.models import WirelessLAN
|
||||||
|
|
||||||
|
|
||||||
@ -1770,6 +1770,7 @@ class ModuleTestCase(
|
|||||||
# bulk creation (need to specify module bays)
|
# bulk creation (need to specify module bays)
|
||||||
ViewTestCases.GetObjectViewTestCase,
|
ViewTestCases.GetObjectViewTestCase,
|
||||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||||
|
ViewTestCases.CreateObjectViewTestCase,
|
||||||
ViewTestCases.EditObjectViewTestCase,
|
ViewTestCases.EditObjectViewTestCase,
|
||||||
ViewTestCases.DeleteObjectViewTestCase,
|
ViewTestCases.DeleteObjectViewTestCase,
|
||||||
ViewTestCases.ListObjectsViewTestCase,
|
ViewTestCases.ListObjectsViewTestCase,
|
||||||
@ -1799,9 +1800,11 @@ class ModuleTestCase(
|
|||||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||||
ModuleBay(device=devices[0], name='Module Bay 2'),
|
ModuleBay(device=devices[0], name='Module Bay 2'),
|
||||||
ModuleBay(device=devices[0], name='Module Bay 3'),
|
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 1'),
|
||||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||||
ModuleBay(device=devices[1], name='Module Bay 3'),
|
ModuleBay(device=devices[1], name='Module Bay 3'),
|
||||||
|
ModuleBay(device=devices[1], name='Module Bay 4'),
|
||||||
)
|
)
|
||||||
ModuleBay.objects.bulk_create(module_bays)
|
ModuleBay.objects.bulk_create(module_bays)
|
||||||
|
|
||||||
@ -1833,6 +1836,39 @@ class ModuleTestCase(
|
|||||||
"Device 2,Module Bay 3,Module Type 3,C,C",
|
"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):
|
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||||
model = ConsolePort
|
model = ConsolePort
|
||||||
|
@ -1063,7 +1063,7 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
|
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
|
||||||
queryset = ConsolePortTemplate.objects.all()
|
queryset = ConsolePortTemplate.objects.all()
|
||||||
form = forms.DeviceTypeComponentCreateForm
|
form = forms.ComponentTemplateCreateForm
|
||||||
model_form = forms.ConsolePortTemplateForm
|
model_form = forms.ConsolePortTemplateForm
|
||||||
|
|
||||||
|
|
||||||
@ -1097,7 +1097,7 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
|
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
|
||||||
queryset = ConsoleServerPortTemplate.objects.all()
|
queryset = ConsoleServerPortTemplate.objects.all()
|
||||||
form = forms.DeviceTypeComponentCreateForm
|
form = forms.ComponentTemplateCreateForm
|
||||||
model_form = forms.ConsoleServerPortTemplateForm
|
model_form = forms.ConsoleServerPortTemplateForm
|
||||||
|
|
||||||
|
|
||||||
@ -1131,7 +1131,7 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
class PowerPortTemplateCreateView(generic.ComponentCreateView):
|
class PowerPortTemplateCreateView(generic.ComponentCreateView):
|
||||||
queryset = PowerPortTemplate.objects.all()
|
queryset = PowerPortTemplate.objects.all()
|
||||||
form = forms.DeviceTypeComponentCreateForm
|
form = forms.ComponentTemplateCreateForm
|
||||||
model_form = forms.PowerPortTemplateForm
|
model_form = forms.PowerPortTemplateForm
|
||||||
|
|
||||||
|
|
||||||
@ -1165,7 +1165,7 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
|
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
|
||||||
queryset = PowerOutletTemplate.objects.all()
|
queryset = PowerOutletTemplate.objects.all()
|
||||||
form = forms.DeviceTypeComponentCreateForm
|
form = forms.ComponentTemplateCreateForm
|
||||||
model_form = forms.PowerOutletTemplateForm
|
model_form = forms.PowerOutletTemplateForm
|
||||||
|
|
||||||
|
|
||||||
@ -1199,7 +1199,7 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
class InterfaceTemplateCreateView(generic.ComponentCreateView):
|
class InterfaceTemplateCreateView(generic.ComponentCreateView):
|
||||||
queryset = InterfaceTemplate.objects.all()
|
queryset = InterfaceTemplate.objects.all()
|
||||||
form = forms.DeviceTypeComponentCreateForm
|
form = forms.ComponentTemplateCreateForm
|
||||||
model_form = forms.InterfaceTemplateForm
|
model_form = forms.InterfaceTemplateForm
|
||||||
|
|
||||||
|
|
||||||
@ -1275,7 +1275,7 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
class RearPortTemplateCreateView(generic.ComponentCreateView):
|
class RearPortTemplateCreateView(generic.ComponentCreateView):
|
||||||
queryset = RearPortTemplate.objects.all()
|
queryset = RearPortTemplate.objects.all()
|
||||||
form = forms.DeviceTypeComponentCreateForm
|
form = forms.ComponentTemplateCreateForm
|
||||||
model_form = forms.RearPortTemplateForm
|
model_form = forms.RearPortTemplateForm
|
||||||
|
|
||||||
|
|
||||||
@ -1377,7 +1377,7 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
class InventoryItemTemplateCreateView(generic.ComponentCreateView):
|
class InventoryItemTemplateCreateView(generic.ComponentCreateView):
|
||||||
queryset = InventoryItemTemplate.objects.all()
|
queryset = InventoryItemTemplate.objects.all()
|
||||||
form = forms.DeviceTypeComponentCreateForm
|
form = forms.ComponentTemplateCreateForm
|
||||||
model_form = forms.InventoryItemTemplateForm
|
model_form = forms.InventoryItemTemplateForm
|
||||||
template_name = 'dcim/inventoryitem_create.html'
|
template_name = 'dcim/inventoryitem_create.html'
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user