From 4587aba1d4af3222ccf1ed8d1dd069b6e48ac988 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 21 Aug 2017 16:53:36 -0400 Subject: [PATCH] Added views to add/remove hosts to/from clusters --- netbox/dcim/filters.py | 5 + .../templates/utilities/obj_bulk_remove.html | 38 ++++++++ netbox/templates/virtualization/cluster.html | 26 +++++- .../virtualization/cluster_add_devices.html | 44 +++++++++ netbox/utilities/forms.py | 13 +++ netbox/virtualization/forms.py | 67 +++++++++++++- netbox/virtualization/urls.py | 2 + netbox/virtualization/views.py | 92 +++++++++++++++++-- 8 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 netbox/templates/utilities/obj_bulk_remove.html create mode 100644 netbox/templates/virtualization/cluster_add_devices.html diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 54a7af4e2..2ff9dfe47 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -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(), diff --git a/netbox/templates/utilities/obj_bulk_remove.html b/netbox/templates/utilities/obj_bulk_remove.html new file mode 100644 index 000000000..a850cf914 --- /dev/null +++ b/netbox/templates/utilities/obj_bulk_remove.html @@ -0,0 +1,38 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %} + +{% block content %} +
+
+
+
Confirm Bulk Removal
+
+ Warning: 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. +
+
+
+
+
+
+
+ {% include 'inc/table.html' %} +
+
+
+
+
+
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 23834e0c4..6b4a46ea7 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -2,7 +2,7 @@ {% load helpers %} {% block content %} -
+
diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html new file mode 100644 index 000000000..6c874d38f --- /dev/null +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -0,0 +1,44 @@ +{% extends '_base.html' %} +{% load static from staticfiles %} +{% load form_helpers %} + +{% block content %} +
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+

{% block title %}Add Devices to Cluster {{ cluster }}{% endblock %}

+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Devices
+
+ {% render_field form.region %} + {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.devices %} +
+
+
+
+
+
+ + Cancel +
+
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index a6fd3d87a..93dbf5234 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -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): diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index cb2288416..f0933e366 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -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 # diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 8542919a4..3e6cf265b 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -28,6 +28,8 @@ urlpatterns = [ url(r'^clusters/(?P\d+)/$', views.ClusterView.as_view(), name='cluster'), url(r'^clusters/(?P\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'), url(r'^clusters/(?P\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'), + url(r'^clusters/(?P\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), + url(r'^clusters/(?P\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), # Virtual machines url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index eca176a77..ff06280b3 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -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 #