From 55e07c1c9a90eafa77c38c3633a45c28db40794d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Nov 2017 16:47:26 -0500 Subject: [PATCH] Initial work on virtual chassis support --- netbox/dcim/api/serializers.py | 51 ++++++++++++++- netbox/dcim/api/urls.py | 4 ++ netbox/dcim/api/views.py | 20 +++++- netbox/dcim/forms.py | 13 +++- .../dcim/migrations/0052_virtual_chassis.py | 48 ++++++++++++++ netbox/dcim/models.py | 62 +++++++++++++++++++ netbox/dcim/tables.py | 27 +++++++- netbox/dcim/urls.py | 4 ++ netbox/dcim/views.py | 21 ++++++- .../templates/dcim/virtualchassis_list.html | 11 ++++ 10 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 netbox/dcim/migrations/0052_virtual_chassis.py create mode 100644 netbox/templates/dcim/virtualchassis_list.html diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9c827912b..5804480a0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -14,7 +14,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VirtualChassis, VCMembership ) from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN @@ -799,3 +799,52 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + + +# +# Virtual chassis +# + +class VirtualChassisSerializer(serializers.ModelSerializer): + site = NestedSiteSerializer() + master = NestedDeviceSerializer() + + class Meta: + model = VirtualChassis + fields = ['id', 'site', 'domain', 'master'] + + +class NestedVirtualChassisSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + + class Meta: + model = VirtualChassis + fields = ['id', 'url'] + + +class WritableVirtualChassisSerializer(ValidatedModelSerializer): + + class Meta: + model = VirtualChassis + fields = ['id', 'site', 'domain', 'master'] + + +# +# Virtual chassis memberships +# + +class VCMembershipSerializer(serializers.ModelSerializer): + virtual_chassis = NestedVirtualChassisSerializer() + device = NestedDeviceSerializer() + + class Meta: + model = VCMembership + fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] + + +class WritableVCMembershipSerializer(serializers.ModelSerializer): + virtual_chassis = serializers.PrimaryKeyRelatedField(queryset=VirtualChassis.objects.all(), required=False) + + class Meta: + model = VCMembership + fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index a03432c61..91ef531ff 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -60,6 +60,10 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections') router.register(r'interface-connections', views.InterfaceConnectionViewSet) +# Virtual chassis +router.register(r'virtual-chassis', views.VirtualChassisViewSet) +router.register(r'vc-memberships', views.VCMembershipViewSet) + # Miscellaneous router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 7185198b1..e782172a2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -15,7 +15,7 @@ from dcim.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, + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis ) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet @@ -396,6 +396,24 @@ class InterfaceConnectionViewSet(ModelViewSet): filter_class = filters.InterfaceConnectionFilter +# +# Virtual chassis +# + +class VirtualChassisViewSet(ModelViewSet): + queryset = VirtualChassis.objects.select_related('master') + serializer_class = serializers.VirtualChassisSerializer + write_serializer_class = serializers.WritableVirtualChassisSerializer + # filter_class = filters.VirtualChassisFilter + + +class VCMembershipViewSet(ModelViewSet): + queryset = VCMembership.objects.select_related('virtual_chassis', 'device') + serializer_class = serializers.VCMembershipSerializer + write_serializer_class = serializers.WritableVCMembershipSerializer + # filter_class = filters.VCMembershipFilter + + # # Miscellaneous # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9d6306d4d..d803c4d04 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, + RackRole, Region, Site, VirtualChassis ) from .constants import * @@ -2170,3 +2170,14 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: model = InventoryItem fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + + +# +# Virtual chassis +# + +class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = VirtualChassis + fields = ['domain'] diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py new file mode 100644 index 000000000..96f38d5c9 --- /dev/null +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-17 20:39 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0051_rackreservation_tenant'), + ] + + operations = [ + migrations.CreateModel( + name='VCMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('master_enabled', models.BooleanField(default=True)), + ('position', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(255)])), + ('priority', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)])), + ('device', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vc_membership', to='dcim.Device')), + ], + options={ + 'verbose_name': 'VC membership', + 'ordering': ['virtual_chassis', 'position'], + }, + ), + migrations.CreateModel( + name='VirtualChassis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(blank=True, max_length=30)), + ('master', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')), + ], + ), + migrations.AddField( + model_name='vcmembership', + name='virtual_chassis', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='dcim.VirtualChassis'), + ), + migrations.AlterUniqueTogether( + name='vcmembership', + unique_together=set([('virtual_chassis', 'position')]), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6324b785a..2d3857c62 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1479,3 +1479,65 @@ class InventoryItem(models.Model): def __str__(self): return self.name + + +# +# Virtual chassis +# + +@python_2_unicode_compatible +class VirtualChassis(models.Model): + """ + A collection of Devices which operate with a shared control plane (e.g. a switch stack). + """ + domain = models.CharField( + max_length=30, + blank=True + ) + master = models.OneToOneField( + to='Device', + on_delete=models.PROTECT, + related_name='vc_master_for' + ) + + def get_absolute_url(self): + return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) + + def clean(self): + + # Check that the master Device is not already assigned to a VirtualChassis. + if VCMembership.objects.filter(device=self.master).exclude(virtual_chassis=self): + raise ValidationError("The master device is already assigned to a different virtual chassis.") + + +@python_2_unicode_compatible +class VCMembership(models.Model): + """ + An attachment of a physical Device to a VirtualChassis. + """ + virtual_chassis = models.ForeignKey( + to='VirtualChassis', + on_delete=models.CASCADE, + related_name='memberships' + ) + device = models.OneToOneField( + to='Device', + on_delete=models.CASCADE, + related_name='vc_membership' + ) + master_enabled = models.BooleanField( + default=True + ) + position = models.PositiveSmallIntegerField( + validators=[MaxValueValidator(255)] + ) + priority = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MaxValueValidator(255)] + ) + + class Meta: + ordering = ['virtual_chassis', 'position'] + unique_together = ['virtual_chassis', 'position'] + verbose_name = 'VC membership' diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 061b3bd9d..4fdbe1ea3 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -7,7 +7,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, VirtualChassis ) REGION_LINK = """ @@ -111,6 +111,12 @@ UTILIZATION_GRAPH = """ {% utilization_graph value %} """ +VIRTUALCHASSIS_ACTIONS = """ +{% if perms.dcim.change_virtualchassis %} + +{% endif %} +""" + # # Regions @@ -523,3 +529,22 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + + +# +# Virtual chassis +# + +class VirtualChassisTable(BaseTable): + pk = ToggleColumn() + master = tables.LinkColumn() + member_count = tables.Column(verbose_name='Members') + actions = tables.TemplateColumn( + template_code=VIRTUALCHASSIS_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = VirtualChassis + fields = ('pk', 'master', 'domain', 'member_count', 'actions') diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a15774569..2cc9ede89 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -207,4 +207,8 @@ urlpatterns = [ url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), + # Virtual chassis + url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2f55d073d..156647f30 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -31,7 +31,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, + RackReservation, RackRole, Region, Site, VirtualChassis ) @@ -1829,3 +1829,22 @@ class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem parent_field = 'device' + + +# +# Virtual chassis +# + +class VirtualChassisListView(ObjectListView): + queryset = VirtualChassis.objects.annotate(member_count=Count('memberships')) + table = tables.VirtualChassisTable + template_name = 'dcim/virtualchassis_list.html' + + +class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_virtualchassis' + model = VirtualChassis + model_form = forms.VirtualChassisForm + + def get_return_url(self, request, obj): + return reverse('dcim:virtualchassis_list') diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html new file mode 100644 index 000000000..f6fe1045c --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_list.html @@ -0,0 +1,11 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block content %} +

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

+
+
+ {% include 'utilities/obj_table.html' %} +
+
+{% endblock %}