Added views to add/remove hosts to/from clusters

This commit is contained in:
Jeremy Stretch 2017-08-21 16:53:36 -04:00
parent 5930a64203
commit 4587aba1d4
8 changed files with 275 additions and 12 deletions

View File

@ -10,6 +10,7 @@ from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
from virtualization.models import Cluster
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
@ -407,6 +408,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=Rack.objects.all(),
label='Rack (ID)',
)
cluster_id = NullableModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
label='VM cluster (ID)',
)
model = django_filters.ModelMultipleChoiceFilter(
name='device_type__slug',
queryset=DeviceType.objects.all(),

View File

@ -0,0 +1,38 @@
{% extends '_base.html' %}
{% load helpers %}
{% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-danger">
<div class="panel-heading"><strong>Confirm Bulk Removal</strong></div>
<div class="panel-body">
<strong>Warning:</strong> The following operation will remove {{ table.rows|length }} {{ obj_type_plural }} from {{ parent_obj }}. Please carefully review the {{ obj_type_plural }} to be removed and confirm below.
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
{% include 'inc/table.html' %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<form action="." method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="text-center">
<button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% load helpers %}
{% block content %}
<div class="row">
<div class="row" xmlns="http://www.w3.org/1999/html">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
@ -89,10 +89,28 @@
<div class="col-md-7">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Devices</strong>
</div>
<div class="panel-body">
<strong>Host Devices</strong>
</div>
{% if perms.virtualization.change_cluster %}
<form action="{% url 'virtualization:cluster_remove_devices' pk=cluster.pk %}" method="post">
{% csrf_token %}
{% endif %}
{% include 'responsive_table.html' with table=device_table %}
{% if perms.virtualization.change_cluster %}
<div class="panel-footer">
<div class="pull-right">
<a href="{% url 'virtualization:cluster_add_devices' pk=cluster.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add devices
</a>
</div>
<button type="submit" name="_remove" class="btn btn-danger primary btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Remove devices
</button>
</div>
</form>
{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,44 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load form_helpers %}
{% block content %}
<form action="." method="post" 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 %}Add Devices to Cluster {{ cluster }}{% endblock %}</h3>
{% 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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Devices</strong></div>
<div class="panel-body">
{% render_field form.region %}
{% render_field form.site %}
{% render_field form.rack %}
{% render_field form.devices %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3 text-right">
<button type="submit" name="_add" class="btn btn-primary">Add Devices</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -187,6 +187,10 @@ class APISelect(SelectWithDisabled):
self.attrs['disabled-indicator'] = disabled_indicator
class APISelectMultiple(APISelect):
allow_multiple_selected = True
class Livesearch(forms.TextInput):
"""
A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
@ -385,6 +389,15 @@ class ChainedModelChoiceField(forms.ModelChoiceField):
super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
See ChainedModelChoiceField
"""
def __init__(self, chains=None, *args, **kwargs):
self.chains = chains
super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs)
class SlugField(forms.SlugField):
def __init__(self, slug_source='name', *args, **kwargs):

View File

@ -1,15 +1,19 @@
from __future__ import unicode_literals
from mptt.forms import TreeNodeChoiceField
from django import forms
from django.db.models import Count
from dcim.formfields import MACAddressFormField
from dcim.models import Device, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedModelChoiceField, ComponentForm,
ExpandableNameField, FilterChoiceField, SlugField,
APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin,
ChainedModelChoiceField, ChainedModelMultipleChoiceField, ComponentForm, ConfirmationForm, ExpandableNameField,
FilterChoiceField, SlugField,
)
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@ -88,6 +92,65 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
)
class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
region = TreeNodeChoiceField(
queryset=Region.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'site'}
)
)
site = ChainedModelChoiceField(
queryset=Site.objects.all(),
chains=(
('region', 'region'),
),
required=False,
widget=APISelect(
api_url='/api/dcim/sites/?region_id={{region}}',
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}}',
attrs={'filter-for': 'devices', 'nullable': 'true'}
)
)
devices = ChainedModelMultipleChoiceField(
queryset=Device.objects.filter(cluster__isnull=True),
chains=(
('site', 'site'),
('rack', 'rack'),
),
label='Device',
required=False,
widget=APISelectMultiple(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
disabled_indicator='cluster'
)
)
class Meta:
fields = ['region', 'site', 'rack', 'devices']
def __init__(self, *args, **kwargs):
super(ClusterAddDevicesForm, self).__init__(*args, **kwargs)
self.fields['devices'].choices = []
class ClusterRemoveDevicesForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
#
# Virtual Machines
#

View File

@ -28,6 +28,8 @@ urlpatterns = [
url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
# Virtual machines
url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),

View File

@ -1,12 +1,14 @@
from __future__ import unicode_literals
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
from django.shortcuts import get_object_or_404, render
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from dcim.models import Device
from dcim.tables import DeviceTable
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
ObjectDeleteView, ObjectEditView, ObjectListView,
@ -96,11 +98,16 @@ class ClusterView(View):
def get(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
devices = Device.objects.filter(cluster=cluster)
devices = Device.objects.filter(cluster=cluster).select_related(
'site', 'rack', 'tenant', 'device_type__manufacturer'
)
device_table = DeviceTable(list(devices), orderable=False)
if request.user.has_perm('virtualization:change_cluster'):
device_table.columns.show('pk')
return render(request, 'virtualization/cluster.html', {
'cluster': cluster,
'devices': devices,
'device_table': device_table,
})
@ -109,9 +116,6 @@ class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
model = Cluster
form_class = forms.ClusterForm
def get_return_url(self, request, obj):
return reverse('virtualization:cluster_list')
class ClusterEditView(ClusterCreateView):
permission_required = 'virtualization.change_cluster'
@ -138,6 +142,82 @@ class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
default_return_url = 'virtualization:cluster_list'
class ClusterAddDevicesView(PermissionRequiredMixin, View):
permission_required = 'virtualization.change_cluster'
form = forms.ClusterAddDevicesForm
template_name = 'virtualization/cluster_add_devices.html'
def get(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
form = self.form()
return render(request, self.template_name, {
'cluster': cluster,
'form': form,
'return_url': reverse('virtualization:cluster', kwargs={'pk': pk}),
})
def post(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
form = self.form(request.POST)
if form.is_valid():
# Assign the selected Devices to the Cluster
devices = form.cleaned_data['devices']
Device.objects.filter(pk__in=devices).update(cluster=cluster)
messages.success(request, "Added {} devices to cluster {}".format(
len(devices), cluster
))
return redirect(cluster.get_absolute_url())
return render(request, self.template_name, {
'cluser': cluster,
'form': form,
'return_url': cluster.get_absolute_url(),
})
class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
permission_required = 'virtualization.change_cluster'
form = forms.ClusterRemoveDevicesForm
template_name = 'utilities/obj_bulk_remove.html'
def post(self, request, pk):
cluster = get_object_or_404(Cluster, pk=pk)
if '_confirm' in request.POST:
form = self.form(request.POST)
if form.is_valid():
# Remove the selected Devices from the Cluster
devices = form.cleaned_data['pk']
Device.objects.filter(pk__in=devices).update(cluster=None)
messages.success(request, "Removed {} devices from cluster {}".format(
len(devices), cluster
))
return redirect(cluster.get_absolute_url())
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
selected_objects = Device.objects.filter(pk__in=form.initial['pk'])
device_table = DeviceTable(list(selected_objects), orderable=False)
return render(request, self.template_name, {
'form': form,
'parent_obj': cluster,
'table': device_table,
'obj_type_plural': 'devices',
'return_url': cluster.get_absolute_url(),
})
#
# Virtual machines
#