Browse Source

Added views to add/remove hosts to/from clusters

Jeremy Stretch 7 years ago
parent
commit
4587aba1d4

+ 5 - 0
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(),

+ 38 - 0
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 %}
+    <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 %}

+ 22 - 4
netbox/templates/virtualization/cluster.html

@@ -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>

+ 44 - 0
netbox/templates/virtualization/cluster_add_devices.html

@@ -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 %}

+ 13 - 0
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):

+ 65 - 2
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
 #

+ 2 - 0
netbox/virtualization/urls.py

@@ -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'),

+ 86 - 6
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
 #