From 5f914130233d8eb8040ae422f8f9811b05f49c2b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2017 12:58:36 -0500 Subject: [PATCH] Added initial UI views for virtual chassis assignment --- netbox/dcim/forms.py | 25 +++++++- netbox/dcim/models.py | 9 ++- netbox/dcim/signals.py | 7 ++- netbox/dcim/urls.py | 2 + netbox/dcim/views.py | 60 ++++++++++++++++++- netbox/templates/dcim/device.html | 33 ++++++++++ netbox/templates/dcim/inc/device_table.html | 5 ++ netbox/templates/dcim/virtualchassis_add.html | 56 +++++++++++++++++ 8 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 netbox/templates/dcim/virtualchassis_add.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d803c4d04..529d9c8d8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -30,7 +30,7 @@ from .models import ( DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Region, Site, VirtualChassis + RackRole, Region, Site, VCMembership, VirtualChassis ) from .constants import * @@ -2181,3 +2181,26 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm): class Meta: model = VirtualChassis fields = ['domain'] + + +class DeviceSelectionForm(forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + +class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField(queryset=Device.objects.all()) + + class Meta: + model = VirtualChassis + fields = ['master', 'domain'] + + def __init__(self, candidate_pks, *args, **kwargs): + super(VirtualChassisCreateForm, self).__init__(*args, **kwargs) + self.fields['master'].queryset = Device.objects.filter(pk__in=candidate_pks) + + +class VCMembershipForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = VCMembership + fields = ['device', 'position', 'priority'] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 24fe183e6..a396251ba 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1030,6 +1030,13 @@ class Device(CreatedUpdatedModel, CustomFieldModel): else: return None + @property + def virtual_chassis(self): + try: + return VCMembership.objects.get(device=self).virtual_chassis + except VCMembership.DoesNotExist: + return None + def get_children(self): """ Return the set of child Devices installed in DeviceBays within this Device. @@ -1534,7 +1541,7 @@ class VCMembership(models.Model): def clean(self): # Check for master conflicts - if self.virtual_chassis and self.is_master: + if getattr(self, 'virtual_chassis', None) and self.is_master: master_conflict = VCMembership.objects.filter(virtual_chassis=self.virtual_chassis).first() if master_conflict: raise ValidationError({ diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index ca33dd251..0e0de8f71 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -11,6 +11,7 @@ def delete_empty_vc(instance, **kwargs): """ When the last VCMembership of a VirtualChassis has been deleted, delete the VirtualChassis as well. """ - virtual_chassis = instance.virtual_chassis - if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): - virtual_chassis.delete() + pass + # virtual_chassis = instance.virtual_chassis + # if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): + # virtual_chassis.delete() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 2cc9ede89..cde30f11f 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -209,6 +209,8 @@ urlpatterns = [ # Virtual chassis url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 156647f30..083f6442b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -6,7 +6,9 @@ from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger +from django.db import transaction from django.db.models import Count, Q +from django.forms import ModelChoiceField, modelformset_factory from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -31,7 +33,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis, ) @@ -832,6 +834,9 @@ class DeviceView(View): services = Service.objects.filter(device=device) secrets = device.secrets.all() + # Find virtual chassis memberships + vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + # Find up to ten devices in the same site with the same functional role for quick reference. related_devices = Device.objects.filter( site=device.site, device_role=device.device_role @@ -854,6 +859,7 @@ class DeviceView(View): 'device_bays': device_bays, 'services': services, 'secrets': secrets, + 'vc_memberships': vc_memberships, 'related_devices': related_devices, 'show_graphs': show_graphs, }) @@ -1841,6 +1847,52 @@ class VirtualChassisListView(ObjectListView): template_name = 'dcim/virtualchassis_list.html' +class VirtualChassisCreateView(PermissionRequiredMixin, View): + permission_required = 'dcim.add_virtualchassis' + + def post(self, request): + + # Get the list of devices being added to a VirtualChassis + pk_form = forms.DeviceSelectionForm(request.POST) + pk_form.full_clean() + device_list = pk_form.cleaned_data['pk'] + + # Generate a custom VCMembershipForm where the device field is limited to only the selected devices + class _VCMembershipForm(forms.VCMembershipForm): + device = ModelChoiceField(queryset=Device.objects.filter(pk__in=device_list)) + + VCMembershipFormSet = modelformset_factory(model=VCMembership, form=_VCMembershipForm, extra=len(device_list)) + + if '_create' in request.POST: + + vc_form = forms.VirtualChassisCreateForm(device_list, request.POST) + formset = VCMembershipFormSet(request.POST) + + if vc_form.is_valid() and formset.is_valid(): + with transaction.atomic(): + virtual_chassis = vc_form.save() + vc_memberships = formset.save(commit=False) + for vcm in vc_memberships: + vcm.virtual_chassis = virtual_chassis + if vcm.device == vc_form.cleaned_data['master']: + vcm.is_master = True + vcm.save() + return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + + else: + + vc_form = forms.VirtualChassisCreateForm(device_list) + initial_data = [{'device': pk, 'position': i} for i, pk in enumerate(device_list, start=1)] + formset = VCMembershipFormSet(queryset=VCMembership.objects.none(), initial=initial_data) + + return render(request, 'dcim/virtualchassis_add.html', { + 'pk_form': pk_form, + 'vc_form': vc_form, + 'formset': formset, + 'return_url': reverse('dcim:device_list'), + }) + + class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_virtualchassis' model = VirtualChassis @@ -1848,3 +1900,9 @@ class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): def get_return_url(self, request, obj): return reverse('dcim:virtualchassis_list') + + +class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_virtualchassis' + model = VirtualChassis + default_return_url = 'dcim:device_list' diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 549b93465..dedc8ec54 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -98,6 +98,39 @@ + {% if vc_memberships %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vcm in vc_memberships %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vcm.device }} + {{ vcm.position }}{{ vcm.is_master }}{{ vcm.priority|default:"" }} +
+ {% if perms.dcim.delete_virtualchassis %} + + {% endif %} +
+ {% endif %}
Management diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html index 33f7e93aa..68570fdf3 100644 --- a/netbox/templates/dcim/inc/device_table.html +++ b/netbox/templates/dcim/inc/device_table.html @@ -16,4 +16,9 @@
{% endif %} + {% if perms.dcim.add_virtualchassis %} + + {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html new file mode 100644 index 000000000..9623fcd05 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -0,0 +1,56 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +
+ {% csrf_token %} + {{ pk_form.pk }} + {{ formset.management_form }} +
+
+

{% block title %}New Virtual Chassis{% endblock %}

+ {% if vc_form.non_field_errors %} +
+
Errors
+
+ {{ vc_form.non_field_errors }} +
+
+ {% endif %} +
+
Virtual Chassis
+
+ {% render_form vc_form %} +
+
+
+
Members
+ + + + + + + + + + {% for form in formset %} + + + + + + {% endfor %} + +
DevicePositionPriority
{{ form.device }}{{ form.position }}{{ form.priority }}
+
+
+
+
+
+ + Cancel +
+
+
+{% endblock %}