Browse Source

Merge pull request #1782 from digitalocean/99-virtual-chassis

Virtual Chassis Support
Jeremy Stretch 7 years ago
parent
commit
02e01b7386

+ 60 - 2
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
@@ -216,7 +216,7 @@ class RackUnitSerializer(serializers.Serializer):
 
 class RackReservationSerializer(serializers.ModelSerializer):
     rack = NestedRackSerializer()
-    user= NestedUserSerializer()
+    user = NestedUserSerializer()
     tenant = NestedTenantSerializer()
 
     class Meta:
@@ -799,3 +799,61 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
     class Meta:
         model = InterfaceConnection
         fields = ['id', 'interface_a', 'interface_b', 'connection_status']
+
+
+#
+# Virtual chassis
+#
+
+class VirtualChassisSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['id', 'domain']
+
+
+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', 'domain']
+
+
+#
+# Virtual chassis memberships
+#
+
+class VCMembershipSerializer(serializers.ModelSerializer):
+    virtual_chassis = NestedVirtualChassisSerializer()
+    device = NestedDeviceSerializer()
+
+    class Meta:
+        model = VCMembership
+        fields = ['id', 'virtual_chassis', 'device', 'position', 'is_master', 'priority']
+
+
+class WritableVCMembershipSerializer(ValidatedModelSerializer):
+
+    class Meta:
+        model = VCMembership
+        fields = ['id', 'virtual_chassis', 'device', 'position', 'is_master', 'priority']
+
+    def validate(self, data):
+
+        # Validate uniqueness of (virtual_chassis, position)
+        validator = UniqueTogetherValidator(queryset=VCMembership.objects.all(), fields=('virtual_chassis', 'position'))
+        validator.set_context(self)
+        validator(data)
+
+        # Enforce model validation
+        super(WritableVCMembershipSerializer, self).validate(data)
+
+        return data

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

+ 33 - 1
netbox/dcim/api/views.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 from collections import OrderedDict
 
 from django.conf import settings
+from django.db import transaction
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from rest_framework.decorators import detail_route
@@ -15,7 +16,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 +398,37 @@ class InterfaceConnectionViewSet(ModelViewSet):
 
 
 #
+# Virtual chassis
+#
+
+class VirtualChassisViewSet(ModelViewSet):
+    queryset = VirtualChassis.objects.all()
+    serializer_class = serializers.VirtualChassisSerializer
+    write_serializer_class = serializers.WritableVirtualChassisSerializer
+
+
+class VCMembershipViewSet(ModelViewSet):
+    queryset = VCMembership.objects.select_related('virtual_chassis', 'device')
+    serializer_class = serializers.VCMembershipSerializer
+    write_serializer_class = serializers.WritableVCMembershipSerializer
+    filter_class = filters.VCMembershipFilter
+
+    def create(self, request, *args, **kwargs):
+
+        with transaction.atomic():
+
+            # Automatically create a new VirtualChassis for new VCMemberships with no VC specified
+            virtual_chassis = request.data.get('virtual_chassis', None)
+            is_master = request.data.get('is_master', False)
+            if not virtual_chassis and is_master:
+                vc = VirtualChassis()
+                vc.save()
+                request.data['virtual_chassis'] = vc.pk
+
+            return super(VCMembershipViewSet, self).create(request, *args, **kwargs)
+
+
+#
 # Miscellaneous
 #
 

+ 3 - 0
netbox/dcim/apps.py

@@ -6,3 +6,6 @@ from django.apps import AppConfig
 class DCIMConfig(AppConfig):
     name = "dcim"
     verbose_name = "DCIM"
+
+    def ready(self):
+        import dcim.signals

+ 10 - 2
netbox/dcim/filters.py

@@ -17,7 +17,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, VCMembership,
 )
 
 
@@ -577,8 +577,9 @@ class InterfaceFilter(django_filters.FilterSet):
     def filter_device(self, queryset, name, value):
         try:
             device = Device.objects.select_related('device_type').get(**{name: value})
+            vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
             ordering = device.device_type.interface_ordering
-            return queryset.filter(device=device).order_naturally(ordering)
+            return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering)
         except Device.DoesNotExist:
             return queryset.none()
 
@@ -631,6 +632,13 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         fields = ['name', 'part_id', 'serial', 'discovered']
 
 
+class VCMembershipFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = VCMembership
+        fields = ['virtual_chassis']
+
+
 class ConsoleConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
         method='filter_site',

+ 69 - 13
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, VCMembership, VirtualChassis
 )
 from .constants import *
 
@@ -773,26 +773,24 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             # Compile list of choices for primary IPv4 and IPv6 addresses
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
+
+                # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
+                interface_ids = self.instance.vc_interfaces.values('pk')
+
                 # Collect interface IPs
                 interface_ips = IPAddress.objects.select_related('interface').filter(
-                    family=family, interface__device=self.instance
+                    family=family, interface_id__in=interface_ids
                 )
                 if interface_ips:
-                    ip_choices.append(
-                        ('Interface IPs', [
-                            (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
-                        ])
-                    )
+                    ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
+                    ip_choices.append(('Interface IPs', ip_list))
                 # Collect NAT IPs
                 nat_ips = IPAddress.objects.select_related('nat_inside').filter(
-                    family=family, nat_inside__interface__device=self.instance
+                    family=family, nat_inside__interface__in=interface_ids
                 )
                 if nat_ips:
-                    ip_choices.append(
-                        ('NAT IPs', [
-                            (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
-                        ])
-                    )
+                    ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
+                    ip_choices.append(('NAT IPs', ip_list))
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
 
             # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
@@ -2170,3 +2168,61 @@ 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):
+    master = forms.ModelChoiceField(queryset=Device.objects.all())
+
+    class Meta:
+        model = VirtualChassis
+        fields = ['domain']
+
+    def __init__(self, *args, **kwargs):
+        super(VirtualChassisForm, self).__init__(*args, **kwargs)
+
+        if self.instance:
+            vc_memberships = self.instance.memberships.all()
+            self.fields['master'].queryset = Device.objects.filter(pk__in=[vcm.device_id for vcm in vc_memberships])
+            self.initial['master'] = self.instance.master
+
+    def save(self, commit=True):
+        instance = super(VirtualChassisForm, self).save(commit=commit)
+
+        # Update the master membership if it has been changed
+        master = self.cleaned_data['master']
+        if instance.pk and instance.master != master:
+            VCMembership.objects.filter(virtual_chassis=self.instance).update(is_master=False)
+            VCMembership.objects.filter(virtual_chassis=self.instance, device=master).update(is_master=True)
+
+        return instance
+
+
+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)
+
+
+#
+# VC memberships
+#
+
+class VCMembershipForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = VCMembership
+        fields = ['position', 'priority']

+ 47 - 0
netbox/dcim/migrations/0052_virtual_chassis.py

@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2017-11-27 17:27
+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')),
+                ('position', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(255)])),
+                ('is_master', models.BooleanField(default=False)),
+                ('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)),
+            ],
+        ),
+        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')]),
+        ),
+    ]

+ 139 - 23
netbox/dcim/models.py

@@ -923,29 +923,28 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             except DeviceType.DoesNotExist:
                 pass
 
-        # Validate primary IPv4 address
-        if self.primary_ip4 and (
-            self.primary_ip4.interface is None or
-            self.primary_ip4.interface.device != self
-        ) and (
-            self.primary_ip4.nat_inside.interface is None or
-            self.primary_ip4.nat_inside.interface.device != self
-        ):
-            raise ValidationError({
-                'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
-            })
-
-        # Validate primary IPv6 address
-        if self.primary_ip6 and (
-            self.primary_ip6.interface is None or
-            self.primary_ip6.interface.device != self
-        ) and (
-            self.primary_ip6.nat_inside.interface is None or
-            self.primary_ip6.nat_inside.interface.device != self
-        ):
-            raise ValidationError({
-                'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
-            })
+        # Validate primary IP addresses
+        vc_interfaces = self.vc_interfaces.all()
+        if self.primary_ip4:
+            if self.primary_ip4.interface in vc_interfaces:
+                pass
+            elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
+                        self.primary_ip4),
+                })
+        if self.primary_ip6:
+            if self.primary_ip6.interface in vc_interfaces:
+                pass
+            elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
+                pass
+            else:
+                raise ValidationError({
+                    'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
+                        self.primary_ip6),
+                })
 
         # A Device can only be assigned to a Cluster in the same Site (or no Site)
         if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
@@ -1035,6 +1034,24 @@ 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
+
+    @property
+    def vc_interfaces(self):
+        """
+        Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
+        Device belonging to the same virtual chassis.
+        """
+        filter = Q(device=self)
+        if hasattr(self, 'vc_membership') and self.vc_membership.is_master:
+            filter |= Q(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis, mgmt_only=False)
+        return Interface.objects.filter(filter)
+
     def get_children(self):
         """
         Return the set of child Devices installed in DeviceBays within this Device.
@@ -1077,6 +1094,9 @@ class ConsolePort(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     # Used for connections export
     def to_csv(self):
         return csv_format([
@@ -1118,6 +1138,9 @@ class ConsoleServerPort(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         # Check that the parent device's DeviceType is a console server
@@ -1154,6 +1177,9 @@ class PowerPort(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     # Used for connections export
     def to_csv(self):
         return csv_format([
@@ -1195,6 +1221,9 @@ class PowerOutlet(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         # Check that the parent device's DeviceType is a PDU
@@ -1274,6 +1303,9 @@ class Interface(models.Model):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return self.parent.get_absolute_url()
+
     def clean(self):
 
         # Check that the parent device's DeviceType is a network device
@@ -1436,6 +1468,9 @@ class DeviceBay(models.Model):
     def __str__(self):
         return '{} - {}'.format(self.device.name, self.name)
 
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
     def clean(self):
 
         # Validate that the parent Device can have DeviceBays
@@ -1480,3 +1515,84 @@ class InventoryItem(models.Model):
 
     def __str__(self):
         return self.name
+
+    def get_absolute_url(self):
+        return self.device.get_absolute_url()
+
+
+#
+# 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
+    )
+
+    def __str__(self):
+        return self.master.name
+
+    def get_absolute_url(self):
+        return self.master.get_absolute_url()
+
+    @property
+    def master(self):
+        master_vcm = VCMembership.objects.filter(virtual_chassis=self, is_master=True).first()
+        return master_vcm.device if master_vcm else None
+
+
+@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'
+    )
+    position = models.PositiveSmallIntegerField(
+        validators=[MaxValueValidator(255)]
+    )
+    is_master = models.BooleanField(
+        default=False
+    )
+    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'
+
+    def __str__(self):
+        return self.device.name
+
+    def clean(self):
+
+        # We have to call this here because it won't be called by VCMembershipForm
+        self.validate_unique()
+
+        # Check for master conflicts
+        if getattr(self, 'virtual_chassis', None) and self.is_master:
+            master_conflict = VCMembership.objects.filter(
+                virtual_chassis=self.virtual_chassis, is_master=True
+            ).exclude(pk=self.pk).first()
+            if master_conflict:
+                raise ValidationError(
+                    "{} has already been designated as the master for this virtual chassis. It must be demoted before "
+                    "a new master can be assigned.".format(master_conflict.device)
+                )

+ 17 - 0
netbox/dcim/signals.py

@@ -0,0 +1,17 @@
+from __future__ import unicode_literals
+
+from django.db.models.signals import post_delete
+from django.dispatch import receiver
+
+from .models import VCMembership
+
+
+@receiver(post_delete, sender=VCMembership)
+def delete_empty_vc(instance, **kwargs):
+    """
+    When the last VCMembership of a VirtualChassis has been deleted, delete the VirtualChassis as well.
+    """
+    pass
+    # virtual_chassis = instance.virtual_chassis
+    # if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists():
+    #     virtual_chassis.delete()

+ 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
@@ -524,3 +530,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')

+ 248 - 2
netbox/dcim/tests/test_api.py

@@ -5,12 +5,12 @@ from django.urls import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
 
-from dcim.constants import IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT
+from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
-    RackReservation, RackRole, Region, Site,
+    RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis,
 )
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from users.models import Token
@@ -2158,3 +2158,249 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase):
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['name'], self.device1.name)
+
+
+class VirtualChassisTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        self.vc1 = VirtualChassis.objects.create(domain='test-domain-1')
+        self.vc2 = VirtualChassis.objects.create(domain='test-domain-2')
+
+    def test_get_virtualchassis(self):
+
+        url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['domain'], self.vc1.domain)
+
+    def test_list_virtualchassis(self):
+
+        url = reverse('dcim-api:virtualchassis-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 2)
+
+    def test_create_virtualchassis(self):
+
+        data = {
+            'domain': 'test-domain-3',
+        }
+
+        url = reverse('dcim-api:virtualchassis-list')
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        vc3 = VirtualChassis.objects.get(pk=response.data['id'])
+        self.assertEqual(vc3.domain, data['domain'])
+
+    def test_update_virtualchassis(self):
+
+        data = {
+            'domain': 'test-domain-x',
+        }
+
+        url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(VirtualChassis.objects.count(), 2)
+        vc1 = VirtualChassis.objects.get(pk=response.data['id'])
+        self.assertEqual(vc1.domain, data['domain'])
+
+    def test_delete_virtualchassis(self):
+
+        url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(VirtualChassis.objects.count(), 1)
+
+
+class VCMembershipTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        site = Site.objects.create(name='Test Site', slug='test-site')
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device Type', slug='test-device-type'
+        )
+        device_role = DeviceRole.objects.create(
+            name='Test Device Role', slug='test-device-role', color='ff0000'
+        )
+
+        # Create 9 member Devices with 12 interfaces each
+        self.device1 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch1', site=site
+        )
+        self.device2 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch2', site=site
+        )
+        self.device3 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch3', site=site
+        )
+        self.device4 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch4', site=site
+        )
+        self.device5 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch5', site=site
+        )
+        self.device6 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch6', site=site
+        )
+        self.device7 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch7', site=site
+        )
+        self.device8 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch8', site=site
+        )
+        self.device9 = Device.objects.create(
+            device_type=device_type, device_role=device_role, name='StackSwitch9', site=site
+        )
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+        for i in range(0, 13):
+            Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+
+        # Create two VirtualChassis with three members each
+        self.vc1 = VirtualChassis.objects.create(domain='test-domain-1')
+        self.vc2 = VirtualChassis.objects.create(domain='test-domain-2')
+        self.vcm1 = VCMembership.objects.create(
+            virtual_chassis=self.vc1, device=self.device1, position=1, priority=10, is_master=True
+        )
+        self.vcm2 = VCMembership.objects.create(
+            virtual_chassis=self.vc1, device=self.device2, position=2, priority=20
+        )
+        self.vcm3 = VCMembership.objects.create(
+            virtual_chassis=self.vc1, device=self.device3, position=3, priority=30
+        )
+        self.vcm4 = VCMembership.objects.create(
+            virtual_chassis=self.vc2, device=self.device4, position=1, priority=10, is_master=True
+        )
+        self.vcm5 = VCMembership.objects.create(
+            virtual_chassis=self.vc2, device=self.device5, position=2, priority=20
+        )
+        self.vcm6 = VCMembership.objects.create(
+            virtual_chassis=self.vc2, device=self.device6, position=3, priority=30
+        )
+
+    def test_get_vcmembership(self):
+
+        url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['virtual_chassis']['id'], self.vc1.pk)
+        self.assertEqual(response.data['device']['id'], self.device1.pk)
+        self.assertEqual(response.data['position'], 1)
+        self.assertEqual(response.data['is_master'], True)
+        self.assertEqual(response.data['priority'], 10)
+
+    def test_list_vcmemberships(self):
+
+        url = reverse('dcim-api:vcmembership-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 6)
+
+    def test_create_vcmembership(self):
+
+        url = reverse('dcim-api:vcmembership-list')
+
+        # Try creating the first membership without is_master. This should fail.
+        data = {
+            'device': self.device7.pk,
+            'position': 1,
+            'priority': 10,
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Add is_master=True and try again. This should succeed.
+        data.update({
+            'is_master': True,
+        })
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        virtualchassis_id = VirtualChassis.objects.get(pk=response.data['virtual_chassis']).pk
+
+        # Try adding a second member with the same position
+        data = {
+            'virtual_chassis': virtualchassis_id,
+            'device': self.device8.pk,
+            'position': 1,
+            'priority': 20,
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Try adding a second member with is_master=True
+        data['is_master'] = True
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Add a second member (valid)
+        del(data['is_master'])
+        data['position'] = 2
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        # Add a third member (valid)
+        data = {
+            'virtual_chassis': virtualchassis_id,
+            'device': self.device9.pk,
+            'position': 3,
+            'priority': 30,
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        self.assertEqual(VCMembership.objects.count(), 9)
+
+    def test_update_vcmembership(self):
+
+        data = {
+            'virtual_chassis': self.vc2.pk,
+            'device': self.device7.pk,
+            'position': 9,
+            'priority': 90,
+        }
+
+        url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        vcm3 = VCMembership.objects.get(pk=response.data['id'])
+        self.assertEqual(vcm3.virtual_chassis.pk, data['virtual_chassis'])
+        self.assertEqual(vcm3.device.pk, data['device'])
+        self.assertEqual(vcm3.position, data['position'])
+        self.assertEqual(vcm3.priority, data['priority'])
+
+    def test_delete_vcmembership(self):
+
+        url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(VCMembership.objects.count(), 5)

+ 10 - 0
netbox/dcim/urls.py

@@ -207,4 +207,14 @@ 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/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
+    url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
+    url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
+
+    # VC memberships
+    url(r'^vc-memberships/(?P<pk>\d+)/edit/$', views.VCMembershipEditView.as_view(), name='vcmembership_edit'),
+    url(r'^vc-memberships/(?P<pk>\d+)/delete/$', views.VCMembershipDeleteView.as_view(), name='vcmembership_delete'),
+
 ]

+ 128 - 34
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
@@ -22,8 +24,8 @@ from ipam.models import Prefix, Service, VLAN
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
-    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
-    ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
+    ObjectEditView, ObjectListView,
 )
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
@@ -32,7 +34,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, VCMembership, VirtualChassis,
 )
 
 
@@ -807,27 +809,44 @@ class DeviceView(View):
         device = get_object_or_404(Device.objects.select_related(
             'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
         ), pk=pk)
+
+        # Find virtual chassis memberships
+        vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device')
+
+        # Console ports
         console_ports = natsorted(
             ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
         )
+
+        # Console server ports
         cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
+
+        # Power ports
         power_ports = natsorted(
             PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name')
         )
+
+        # Power outlets
         power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port')
-        interfaces = Interface.objects.order_naturally(
+
+        # Interfaces
+        interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
-        ).filter(
-            device=device
         ).select_related(
             'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
             'circuit_termination__circuit'
         ).prefetch_related('ip_addresses')
+
+        # Device bays
         device_bays = natsorted(
             DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
             key=attrgetter('name')
         )
+
+        # Services
         services = Service.objects.filter(device=device)
+
+        # Secrets
         secrets = device.secrets.all()
 
         # Find up to ten devices in the same site with the same functional role for quick reference.
@@ -852,6 +871,7 @@ class DeviceView(View):
             'device_bays': device_bays,
             'services': services,
             'secrets': secrets,
+            'vc_memberships': vc_memberships,
             'related_devices': related_devices,
             'show_graphs': show_graphs,
         })
@@ -1074,17 +1094,15 @@ def consoleport_disconnect(request, pk):
     })
 
 
-class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
+class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_consoleport'
     model = ConsolePort
-    parent_field = 'device'
     model_form = forms.ConsolePortForm
 
 
-class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_consoleport'
     model = ConsolePort
-    parent_field = 'device'
 
 
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1194,17 +1212,15 @@ def consoleserverport_disconnect(request, pk):
     })
 
 
-class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
+class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_consoleserverport'
     model = ConsoleServerPort
-    parent_field = 'device'
     model_form = forms.ConsoleServerPortForm
 
 
-class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     model = ConsoleServerPort
-    parent_field = 'device'
 
 
 class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1313,17 +1329,15 @@ def powerport_disconnect(request, pk):
     })
 
 
-class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
+class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_powerport'
     model = PowerPort
-    parent_field = 'device'
     model_form = forms.PowerPortForm
 
 
-class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_powerport'
     model = PowerPort
-    parent_field = 'device'
 
 
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1433,17 +1447,15 @@ def poweroutlet_disconnect(request, pk):
     })
 
 
-class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
+class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_poweroutlet'
     model = PowerOutlet
-    parent_field = 'device'
     model_form = forms.PowerOutletForm
 
 
-class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     model = PowerOutlet
-    parent_field = 'device'
 
 
 class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1478,18 +1490,16 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     template_name = 'dcim/device_component_add.html'
 
 
-class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
+class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_interface'
     model = Interface
-    parent_field = 'device'
     model_form = forms.InterfaceForm
     template_name = 'dcim/interface_edit.html'
 
 
-class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_interface'
     model = Interface
-    parent_field = 'device'
 
 
 class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1533,17 +1543,15 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
     template_name = 'dcim/device_component_add.html'
 
 
-class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
+class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_devicebay'
     model = DeviceBay
-    parent_field = 'device'
     model_form = forms.DeviceBayForm
 
 
-class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_devicebay'
     model = DeviceBay
-    parent_field = 'device'
 
 
 @permission_required('dcim.change_devicebay')
@@ -1811,10 +1819,9 @@ class InterfaceConnectionsListView(ObjectListView):
 # Inventory items
 #
 
-class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
+class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_inventoryitem'
     model = InventoryItem
-    parent_field = 'device'
     model_form = forms.InventoryItemForm
 
     def alter_obj(self, obj, request, url_args, url_kwargs):
@@ -1823,7 +1830,94 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
         return obj
 
 
-class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     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 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))
+
+            class Meta:
+                model = VCMembership
+                fields = ['device', 'position', 'priority']
+
+        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
+    model_form = forms.VirtualChassisForm
+    template_name = 'dcim/virtualchassis_edit.html'
+
+
+class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_virtualchassis'
+    model = VirtualChassis
+    default_return_url = 'dcim:device_list'
+
+
+#
+# VC memberships
+#
+
+class VCMembershipEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_vcmembership'
+    model = VCMembership
+    model_form = forms.VCMembershipForm
+
+
+class VCMembershipDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_vcmembership'
+    model = VCMembership

+ 37 - 0
netbox/templates/dcim/device.html

@@ -98,6 +98,43 @@
                 </tr>
             </table>
         </div>
+        {% if vc_memberships %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Virtual Chassis</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <th>Device</th>
+                        <th>Position</th>
+                        <th>Master</th>
+                        <th>Priority</th>
+                    </tr>
+                    {% for vcm in vc_memberships %}
+                        <tr{% if vcm.device == device %} class="success"{% endif %}>
+                            <td>
+                                <a href="{{ vcm.device.get_absolute_url }}">{{ vcm.device }}</a>
+                            </td>
+                            <td>{{ vcm.position }}</td>
+                            <td>{% if vcm.is_master %}<i class="fa fa-check"></i>{% endif %}</td>
+                            <td>{{ vcm.priority|default:"" }}</td>
+                        </tr>
+                    {% endfor %}
+                </table>
+                <div class="panel-footer text-right">
+                    {% if perms.dcim.change_virtualchassis %}
+                        <a href="{% url 'dcim:virtualchassis_edit' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Virtual Chassis
+                        </a>
+                    {% endif %}
+                    {% if perms.dcim.delete_virtualchassis %}
+                        <a href="{% url 'dcim:virtualchassis_delete' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Virtual Chassis
+                        </a>
+                    {% endif %}
+                </div>
+            </div>
+        {% endif %}
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Management</strong>

+ 5 - 0
netbox/templates/dcim/inc/device_table.html

@@ -16,4 +16,9 @@
             </ul>
         </div>
     {% endif %}
+    {% if perms.dcim.add_virtualchassis %}
+        <button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
+            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
+        </button>
+    {% endif %}
 {% endblock %}

+ 8 - 6
netbox/templates/dcim/inc/interface.html

@@ -1,9 +1,11 @@
 <tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}" id="iface_{{ iface.name }}">
 
-    {# Checkbox #}
+    {# Checkbox (exclude VC members) #}
     {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
         <td class="pk">
-            <input name="pk" type="checkbox" value="{{ iface.pk }}" />
+            {% if iface.device == device %}
+                <input name="pk" type="checkbox" value="{{ iface.pk }}" />
+            {% endif %}
         </td>
     {% endif %}
 
@@ -105,16 +107,16 @@
                     <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
                         <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
                     </button>
-                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
+                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}&return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
                         <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                 {% else %}
-                    <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
+                    <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
                         <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     </a>
                 {% endif %}
             {% endif %}
-            <a href="{% if iface.device %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}" class="btn btn-info btn-xs" title="Edit interface">
+            <a href="{% if iface.device %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
         {% endif %}
@@ -124,7 +126,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
             {% else %}
-                <a href="{% if iface.device %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}" class="btn btn-danger btn-xs" title="Delete interface">
+                <a href="{% if iface.device %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </a>
             {% endif %}

+ 56 - 0
netbox/templates/dcim/virtualchassis_add.html

@@ -0,0 +1,56 @@
+{% extends '_base.html' %}
+{% load form_helpers %}
+
+{% block content %}
+    <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
+        {% csrf_token %}
+        {{ pk_form.pk }}
+        {{ formset.management_form }}
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3">
+                <h3>{% block title %}New Virtual Chassis{% endblock %}</h3>
+                {% if vc_form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ vc_form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Virtual Chassis</strong></div>
+                    <div class="table panel-body">
+                        {% render_form vc_form %}
+                    </div>
+                </div>
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Members</strong></div>
+                    <table class="table panel-body">
+                        <thead>
+                            <tr>
+                                <th>Device</th>
+                                <th>Position</th>
+                                <th>Priority</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for form in formset %}
+                                <tr>
+                                    <td>{{ form.device }}</td>
+                                    <td>{{ form.position }}</td>
+                                    <td>{{ form.priority }}</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3 text-right">
+                <button type="submit" name="_create" class="btn btn-primary">Create</button>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    </form>
+{% endblock %}

+ 44 - 0
netbox/templates/dcim/virtualchassis_edit.html

@@ -0,0 +1,44 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block content %}
+    {{ block.super }}
+    <div class="row">
+        <div class="col-md-6 col-md-offset-3">
+            <h3>Memberships</h3>
+            <div class="panel panel-default">
+                <table class="table panel-body">
+                    <tr class="table-headings">
+                        <th>Device</th>
+                        <th>Position</th>
+                        <th>Master</th>
+                        <th>Priority</th>
+                        <th></th>
+                    </tr>
+                    {% for vcm in form.instance.memberships.all %}
+                        <tr>
+                            <td>
+                                <a href="{{ vcm.device.get_absolute_url }}">{{ vcm.device }}</a>
+                            </td>
+                            <td>{{ vcm.position }}</td>
+                            <td>{% if vcm.is_master %}<i class="fa fa-check"></i>{% endif %}</td>
+                            <td>{{ vcm.priority|default:"" }}</td>
+                            <td class="text-right">
+                                 {% if perms.dcim.change_vcmembership %}
+                                    <a href="{% url 'dcim:vcmembership_edit' pk=vcm.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=vcm.virtual_chassis.pk %}" class="btn btn-warning btn-xs">
+                                        <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
+                                    </a>
+                                {% endif %}
+                                 {% if perms.dcim.delete_vcmembership %}
+                                    <a href="{% url 'dcim:vcmembership_delete' pk=vcm.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=vcm.virtual_chassis.pk %}" class="btn btn-danger btn-xs">
+                                        <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                                    </a>
+                                {% endif %}
+                            </td>
+                        </tr>
+                    {% endfor %}
+                </table>
+            </div>
+        </div>
+    </div>
+{% endblock %}

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

+ 0 - 14
netbox/utilities/views.py

@@ -803,20 +803,6 @@ class ComponentCreateView(View):
         })
 
 
-class ComponentEditView(ObjectEditView):
-    parent_field = None
-
-    def get_return_url(self, request, obj):
-        return getattr(obj, self.parent_field).get_absolute_url()
-
-
-class ComponentDeleteView(ObjectDeleteView):
-    parent_field = None
-
-    def get_return_url(self, request, obj):
-        return getattr(obj, self.parent_field).get_absolute_url()
-
-
 class BulkComponentCreateView(View):
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.

+ 4 - 6
netbox/virtualization/views.py

@@ -11,8 +11,8 @@ from dcim.models import Device, Interface
 from dcim.tables import DeviceTable
 from ipam.models import Service
 from utilities.views import (
-    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
-    ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
+    ObjectEditView, ObjectListView,
 )
 from . import filters, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -331,17 +331,15 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     template_name = 'virtualization/virtualmachine_component_add.html'
 
 
-class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
+class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_interface'
     model = Interface
-    parent_field = 'virtual_machine'
     model_form = forms.InterfaceForm
 
 
-class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_interface'
     model = Interface
-    parent_field = 'virtual_machine'
 
 
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):