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
|
||||
|
||||
|
||||
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(),
|
||||
|
@ -128,6 +128,7 @@ urlpatterns = [
|
||||
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+)/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+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
|
||||
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 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 = '{} <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):
|
||||
permission_required = 'dcim.add_device'
|
||||
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 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 %}
|
||||
<a href="{% url 'dcim:device_edit' pk=device.pk %}" class="btn btn-warning">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
|
||||
|
Loading…
Reference in New Issue
Block a user