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 extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
 from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
+from virtualization.models import Cluster
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
     DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
@@ -407,6 +408,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label='Rack (ID)',
         label='Rack (ID)',
     )
     )
+    cluster_id = NullableModelMultipleChoiceFilter(
+        queryset=Cluster.objects.all(),
+        label='VM cluster (ID)',
+    )
     model = django_filters.ModelMultipleChoiceFilter(
     model = django_filters.ModelMultipleChoiceFilter(
         name='device_type__slug',
         name='device_type__slug',
         queryset=DeviceType.objects.all(),
         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 %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
-<div class="row">
+<div class="row" xmlns="http://www.w3.org/1999/html">
     <div class="col-sm-8 col-md-9">
     <div class="col-sm-8 col-md-9">
         <ol class="breadcrumb">
         <ol class="breadcrumb">
             <li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
             <li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
@@ -89,10 +89,28 @@
     <div class="col-md-7">
     <div class="col-md-7">
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
-                <strong>Devices</strong>
-            </div>
-            <div class="panel-body">
+                <strong>Host Devices</strong>
             </div>
             </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>
 	</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
             self.attrs['disabled-indicator'] = disabled_indicator
 
 
 
 
+class APISelectMultiple(APISelect):
+    allow_multiple_selected = True
+
+
 class Livesearch(forms.TextInput):
 class Livesearch(forms.TextInput):
     """
     """
     A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
     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)
         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):
 class SlugField(forms.SlugField):
 
 
     def __init__(self, slug_source='name', *args, **kwargs):
     def __init__(self, slug_source='name', *args, **kwargs):

+ 65 - 2
netbox/virtualization/forms.py

@@ -1,15 +1,19 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+from mptt.forms import TreeNodeChoiceField
+
 from django import forms
 from django import forms
 from django.db.models import Count
 from django.db.models import Count
 
 
 from dcim.formfields import MACAddressFormField
 from dcim.formfields import MACAddressFormField
+from dcim.models import Device, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 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
 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
 # 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+)/$', 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+)/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+)/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
     # Virtual machines
     url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
     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 __future__ import unicode_literals
 
 
+from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.db.models import Count
 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.urls import reverse
 from django.views.generic import View
 from django.views.generic import View
 
 
 from dcim.models import Device
 from dcim.models import Device
+from dcim.tables import DeviceTable
 from utilities.views import (
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
     BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
     ObjectDeleteView, ObjectEditView, ObjectListView,
     ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -96,11 +98,16 @@ class ClusterView(View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         cluster = get_object_or_404(Cluster, pk=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', {
         return render(request, 'virtualization/cluster.html', {
             'cluster': cluster,
             'cluster': cluster,
-            'devices': devices,
+            'device_table': device_table,
         })
         })
 
 
 
 
@@ -109,9 +116,6 @@ class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
     model = Cluster
     model = Cluster
     form_class = forms.ClusterForm
     form_class = forms.ClusterForm
 
 
-    def get_return_url(self, request, obj):
-        return reverse('virtualization:cluster_list')
-
 
 
 class ClusterEditView(ClusterCreateView):
 class ClusterEditView(ClusterCreateView):
     permission_required = 'virtualization.change_cluster'
     permission_required = 'virtualization.change_cluster'
@@ -138,6 +142,82 @@ class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'virtualization:cluster_list'
     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
 # Virtual machines
 #
 #