From 6da7813d2f9e1c2fbd61745e2c588e488e66cd42 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 31 Jan 2018 03:34:17 -0500 Subject: [PATCH] implements #33 - device clone --- netbox/dcim/forms.py | 111 ++++++++++++++++++ netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 113 +++++++++++++++++++ netbox/templates/dcim/device_clone.html | 82 ++++++++++++++ netbox/templates/dcim/inc/clone.html | 42 +++++++ netbox/templates/dcim/inc/device_header.html | 6 + 6 files changed, 355 insertions(+) create mode 100644 netbox/templates/dcim/device_clone.html create mode 100644 netbox/templates/dcim/inc/clone.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e051e33e5..e7fd9f7a9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -827,6 +827,117 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id +class DeviceCloneForm(BootstrapMixin, TenancyForm, CustomFieldForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + display_field='display_name', + attrs={'filter-for': 'position'} + ) + ) + position = forms.TypedChoiceField( + required=False, + empty_value=None, + help_text="The lowest-numbered unit occupied by the device", + widget=APISelect( + api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', + disabled_indicator='device' + ) + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + widget=forms.Select( + attrs={'filter-for': 'device_type'} + ) + ) + device_type = ChainedModelChoiceField( + queryset=DeviceType.objects.all(), + chains=( + ('manufacturer', 'manufacturer'), + ), + label='Device type', + widget=APISelect( + api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', + display_field='model' + ) + ) + comments = CommentField() + + class Meta: + model = Device + labels = { + 'name': 'New device name' + } + fields = [ + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', + 'platform', 'tenant_group', 'tenant', 'comments', + ] + help_texts = { + 'device_role': "The function this device serves", + 'serial': "Chassis serial number", + } + widgets = { + 'face': forms.Select(attrs={'filter-for': 'position'}), + } + + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + + super(DeviceCloneForm, self).__init__(*args, **kwargs) + + # Rack position + pk = self.instance.pk if self.instance.pk else None + try: + if self.is_bound and self.data.get('rack') and str(self.data.get('face')): + position_choices = Rack.objects.get(pk=self.data['rack'])\ + .get_rack_units(face=self.data.get('face'), exclude=pk) + elif self.initial.get('rack') and str(self.initial.get('face')): + position_choices = Rack.objects.get(pk=self.initial['rack'])\ + .get_rack_units(face=self.initial.get('face'), exclude=pk) + else: + position_choices = [] + except Rack.DoesNotExist: + position_choices = [] + self.fields['position'].choices = [('', '---------')] + [ + (p['id'], { + 'label': p['name'], + 'disabled': bool(p['device'] and p['id'] != self.initial.get('position')), + }) for p in position_choices + ] + + # Disable rack assignment if this is a child device installed in a parent device + if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): + self.fields['site'].disabled = True + self.fields['rack'].disabled = True + self.initial['site'] = self.instance.parent_bay.device.site_id + self.initial['rack'] = self.instance.parent_bay.device.rack_id + + # set initial manufacturer + self.initial['manufacturer'] = instance.device_type.manufacturer.id + + # clear device specific fields + self.initial['name'] = '' + self.initial['position'] = '' + self.initial['serial'] = '' + self.initial['asset_tag'] = '' + + # set the instance to None + self.instance = Device() + + class BaseDeviceCSVForm(forms.ModelForm): device_role = forms.ModelChoiceField( queryset=DeviceRole.objects.all(), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a15774569..2267d1ee4 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -128,6 +128,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), + url(r'^devices/(?P\d+)/clone/$', views.DeviceCloneView.as_view(), name='device_clone'), url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0dc393cfb..0ce60e5b4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from collections import defaultdict from operator import attrgetter from django.contrib import messages @@ -941,6 +942,118 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:device_list' +class DeviceCloneView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_device' + model = Device + model_form = forms.DeviceCloneForm + template_name = 'dcim/device_clone.html' + default_return_url = 'dcim:device_list' + + def post(self, request, *args, **kwargs): + + obj = self.get_object(kwargs) + obj = self.alter_obj(obj, request, args, kwargs) + form = self.model_form(request.POST, request.FILES, instance=obj) + + if form.is_valid(): + new_obj = form.save() + + # When a new device is initialized, components are created from the device type templates. + # Becasue these items can be modified after that point and becasue we have no easy why of + # knowing what components are sourced from the template vs created as one offs, we delete + # them all and clone them from the source device. + + # bulk create clones of interfaces + Interface.objects.filter(device=new_obj).delete() + interfaces = Interface.objects.filter(device=obj) + lag_members = defaultdict(list) + for interface in interfaces: + interface.pk = None + interface.device = new_obj + if interface.lag is not None: + lag_members[interface.lag.name].append(interface) + interface.lag = None + interface.description = '' + if interfaces: + Interface.objects.bulk_create(interfaces) + # reassociate lag members now that pk's exist + for lag_name, member_interfaces in lag_members.items(): + lag = Interface.objects.get(device=new_obj, name=lag_name) + Interface.objects.filter(id__in=[o.id for o in member_interfaces]).update(lag=lag) + + # bulk create clones of console ports + ConsolePort.objects.filter(device=new_obj).delete() + console_ports = ConsolePort.objects.filter(device=obj) + for console_port in console_ports: + console_port.pk = None + console_port.device = new_obj + console_port.cs_port = None + if console_ports: + ConsolePort.objects.bulk_create(console_ports) + + # bulk create clones of console server ports + ConsoleServerPort.objects.filter(device=new_obj).delete() + console_server_ports = ConsoleServerPort.objects.filter(device=obj) + for console_server_port in console_server_ports: + console_server_port.pk = None + console_server_port.device = new_obj + if console_server_ports: + ConsoleServerPort.objects.bulk_create(console_server_ports) + + # bulk create clones of power ports + PowerPort.objects.filter(device=new_obj).delete() + power_ports = PowerPort.objects.filter(device=obj) + for power_port in power_ports: + power_port.pk = None + power_port.device = new_obj + power_port.power_outlet = None + if power_ports: + PowerPort.objects.bulk_create(power_ports) + + # bulk create clones of power outlets + PowerOutlet.objects.filter(device=new_obj).delete() + power_outlets = PowerOutlet.objects.filter(device=obj) + for power_outlet in power_outlets: + power_outlet.pk = None + power_outlet.device = new_obj + if power_outlets: + PowerOutlet.objects.bulk_create(power_outlets) + + # bulk create clones of device bays + DeviceBay.objects.filter(device=new_obj).delete() + device_bays = DeviceBay.objects.filter(device=obj) + for device_bay in device_bays: + device_bay.pk = None + device_bay.device = new_obj + device_bay.installed_device = None + if device_bays: + DeviceBay.objects.bulk_create(device_bays) + + msg = 'Created {}'.format(self.model._meta.verbose_name) + if hasattr(new_obj, 'get_absolute_url'): + msg = '{} {}'.format(msg, new_obj.get_absolute_url(), escape(new_obj)) + else: + msg = '{} {}'.format(msg, escape(new_obj)) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_create(request.user, new_obj, msg) + + if '_cloneanother' in request.POST: + return redirect(request.get_full_path()) + + return_url = form.cleaned_data.get('return_url') + if return_url is not None and is_safe_url(url=return_url, host=request.get_host()): + return redirect(return_url) + else: + return redirect(self.get_return_url(request, new_obj)) + + return render(request, self.template_name, { + 'obj': obj, + 'obj_type': self.model._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, obj), + }) + + class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_device' model_form = forms.DeviceCSVForm diff --git a/netbox/templates/dcim/device_clone.html b/netbox/templates/dcim/device_clone.html new file mode 100644 index 000000000..535c0a70b --- /dev/null +++ b/netbox/templates/dcim/device_clone.html @@ -0,0 +1,82 @@ +{% extends 'dcim/inc/clone.html' %} +{% load form_helpers %} + +{% block form %} +
+
Device
+
+ {% render_field form.name %} + {% render_field form.device_role %} +
+
+
+
Hardware
+
+ {% render_field form.manufacturer %} + {% render_field form.device_type %} + {% render_field form.serial %} + {% render_field form.asset_tag %} +
+
+
+
Location
+
+ {% render_field form.site %} + {% render_field form.rack %} + {% if obj.device_type.is_child_device and obj.parent_bay %} +
+ + +
+
+ +
+

+ {{ obj.parent_bay.name }} + {% if perms.dcim.change_devicebay %} + + Remove + + {% endif %} +

+
+
+ {% else %} + {% render_field form.face %} + {% render_field form.position %} + {% endif %} +
+
+
+
Management
+
+ {% render_field form.status %} + {% render_field form.platform %} +
+
+
+
Tenancy
+
+ {% render_field form.tenant_group %} + {% render_field form.tenant %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Comments
+
+ {% render_field form.comments %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/inc/clone.html b/netbox/templates/dcim/inc/clone.html new file mode 100644 index 000000000..43834b284 --- /dev/null +++ b/netbox/templates/dcim/inc/clone.html @@ -0,0 +1,42 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+

{% block title %}Cloning {{ obj_type }} {{ obj }}{% endblock %}

+ {% block tabs %}{% endblock %} + {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} + {% block form %} +
+
{{ obj_type|capfirst }}
+
+ {% render_form form %} +
+
+ {% endblock %} +
+
+
+
+ {% if obj.pk %} + + + {% endif %} + Cancel +
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 73b5845ef..28c95ce4b 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -27,6 +27,12 @@
+ {% if perms.dcim.add_device %} + + + Clone this device + + {% endif %} {% if perms.dcim.change_device %}