Browse Source

Initial work on virtual chassis support

Jeremy Stretch 7 years ago
parent
commit
55e07c1c9a

+ 50 - 1
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']

+ 4 - 0
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')
 

+ 19 - 1
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
@@ -397,6 +397,24 @@ class InterfaceConnectionViewSet(ModelViewSet):
 
 
 #
+# 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
 #
 

+ 12 - 1
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']

+ 48 - 0
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')]),
+        ),
+    ]

+ 62 - 0
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'

+ 26 - 1
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 %}
+    <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% 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')

+ 4 - 0
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<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
+
 ]

+ 20 - 1
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')

+ 11 - 0
netbox/templates/dcim/virtualchassis_list.html

@@ -0,0 +1,11 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-12">
+        {% include 'utilities/obj_table.html' %}
+    </div>
+</div>
+{% endblock %}