mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-08 00:28:16 -06:00
implements #33 - device clone
This commit is contained in:
parent
9ea8dca4e3
commit
6da7813d2f
@ -827,6 +827,117 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
self.initial['rack'] = self.instance.parent_bay.device.rack_id
|
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):
|
class BaseDeviceCSVForm(forms.ModelForm):
|
||||||
device_role = forms.ModelChoiceField(
|
device_role = forms.ModelChoiceField(
|
||||||
queryset=DeviceRole.objects.all(),
|
queryset=DeviceRole.objects.all(),
|
||||||
|
@ -128,6 +128,7 @@ urlpatterns = [
|
|||||||
url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
|
url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
|
||||||
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
|
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
|
||||||
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
|
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
|
||||||
|
url(r'^devices/(?P<pk>\d+)/clone/$', views.DeviceCloneView.as_view(), name='device_clone'),
|
||||||
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
|
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
|
||||||
url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
|
url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
|
||||||
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@ -941,6 +942,118 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
|||||||
default_return_url = 'dcim:device_list'
|
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 = '{} <a href="{}">{}</a>'.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):
|
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||||
permission_required = 'dcim.add_device'
|
permission_required = 'dcim.add_device'
|
||||||
model_form = forms.DeviceCSVForm
|
model_form = forms.DeviceCSVForm
|
||||||
|
82
netbox/templates/dcim/device_clone.html
Normal file
82
netbox/templates/dcim/device_clone.html
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{% extends 'dcim/inc/clone.html' %}
|
||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
|
{% block form %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Device</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_field form.name %}
|
||||||
|
{% render_field form.device_role %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Hardware</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_field form.manufacturer %}
|
||||||
|
{% render_field form.device_type %}
|
||||||
|
{% render_field form.serial %}
|
||||||
|
{% render_field form.asset_tag %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Location</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_field form.site %}
|
||||||
|
{% render_field form.rack %}
|
||||||
|
{% if obj.device_type.is_child_device and obj.parent_bay %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">Parent device</label>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<p class="form-control-static">
|
||||||
|
<a href="{% url 'dcim:device' pk=obj.parent_bay.device.pk %}">{{ obj.parent_bay.device }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">Parent bay</label>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<p class="form-control-static">
|
||||||
|
{{ obj.parent_bay.name }}
|
||||||
|
{% if perms.dcim.change_devicebay %}
|
||||||
|
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" class="btn btn-danger btn-xs">
|
||||||
|
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Remove device"></i> Remove
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% render_field form.face %}
|
||||||
|
{% render_field form.position %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Management</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_field form.status %}
|
||||||
|
{% render_field form.platform %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Tenancy</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_field form.tenant_group %}
|
||||||
|
{% render_field form.tenant %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if form.custom_fields %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_custom_fields form %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Comments</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_field form.comments %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
42
netbox/templates/dcim/inc/clone.html
Normal file
42
netbox/templates/dcim/inc/clone.html
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{% extends '_base.html' %}
|
||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in form.hidden_fields %}
|
||||||
|
{{ field }}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
<h3>{% block title %}Cloning {{ obj_type }} {{ obj }}{% endblock %}</h3>
|
||||||
|
{% block tabs %}{% endblock %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="panel panel-danger">
|
||||||
|
<div class="panel-heading"><strong>Errors</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% block form %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_form form %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3 text-right">
|
||||||
|
{% if obj.pk %}
|
||||||
|
<button type="submit" name="_clone" class="btn btn-primary">Clone</button>
|
||||||
|
<button type="submit" name="_cloneanother" class="btn btn-primary">Clone and Clone Another</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
@ -27,6 +27,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pull-right">
|
<div class="pull-right">
|
||||||
|
{% if perms.dcim.add_device %}
|
||||||
|
<a href="{% url 'dcim:device_clone' pk=device.pk %}" class="btn btn-warning">
|
||||||
|
<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span>
|
||||||
|
Clone this device
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if perms.dcim.change_device %}
|
{% if perms.dcim.change_device %}
|
||||||
<a href="{% url 'dcim:device_edit' pk=device.pk %}" class="btn btn-warning">
|
<a href="{% url 'dcim:device_edit' pk=device.pk %}" class="btn btn-warning">
|
||||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
|
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
|
||||||
|
Loading…
Reference in New Issue
Block a user