Browse Source

Collapsed VCMembership into the Device model (WIP)

Jeremy Stretch 7 years ago
parent
commit
a4019be28c

+ 6 - 55
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, VirtualChassis, VCMembership
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress, VLAN
@@ -489,7 +489,6 @@ class DeviceSerializer(CustomFieldModelSerializer):
     primary_ip4 = DeviceIPAddressSerializer()
     primary_ip6 = DeviceIPAddressSerializer()
     parent_device = serializers.SerializerMethodField()
-    virtual_chassis = serializers.SerializerMethodField()
     cluster = NestedClusterSerializer()
 
     class Meta:
@@ -497,7 +496,8 @@ class DeviceSerializer(CustomFieldModelSerializer):
         fields = [
             'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'rack', 'position', 'face', 'parent_device', 'virtual_chassis', 'status', 'primary_ip',
-            'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields', 'created', 'last_updated',
+            'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'comments', 'custom_fields', 'created',
+            'last_updated',
         ]
 
     def get_parent_device(self, obj):
@@ -510,16 +510,6 @@ class DeviceSerializer(CustomFieldModelSerializer):
         data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
         return data
 
-    def get_virtual_chassis(self, obj):
-        try:
-            vc_membership = obj.vc_membership
-        except VCMembership.DoesNotExist:
-            return None
-        context = {'request': self.context['request']}
-        data = NestedVirtualChassisSerializer(instance=vc_membership.virtual_chassis, context=context).data
-        data['vc_membership'] = NestedVCMembershipSerializer(instance=vc_membership, context=context).data
-        return data
-
 
 class WritableDeviceSerializer(CustomFieldModelSerializer):
 
@@ -833,10 +823,11 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
 #
 
 class VirtualChassisSerializer(serializers.ModelSerializer):
+    master = NestedDeviceSerializer()
 
     class Meta:
         model = VirtualChassis
-        fields = ['id', 'domain']
+        fields = ['id', 'master', 'domain']
 
 
 class NestedVirtualChassisSerializer(serializers.ModelSerializer):
@@ -851,44 +842,4 @@ 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 NestedVCMembershipSerializer(serializers.ModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:vcmembership-detail')
-
-    class Meta:
-        model = VCMembership
-        fields = ['id', 'url', '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
+        fields = ['id', 'master', 'domain']

+ 0 - 1
netbox/dcim/api/urls.py

@@ -62,7 +62,6 @@ 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')

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

@@ -16,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, VCMembership, VirtualChassis
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
@@ -403,32 +403,6 @@ class VirtualChassisViewSet(ModelViewSet):
     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
-            if isinstance(request.data, list):
-                for i, vcm in enumerate(request.data):
-                    if not vcm.get('virtual_chassis') and vcm.get('is_master'):
-                        vc = VirtualChassis()
-                        vc.save()
-                        request.data[i]['virtual_chassis'] = vc.pk
-            else:
-                if not request.data.get('virtual_chassis') and request.data.get('is_master'):
-                    vc = VirtualChassis()
-                    vc.save()
-                    request.data['virtual_chassis'] = vc.pk
-
-            return super(VCMembershipViewSet, self).create(request, *args, **kwargs)
-
-
 #
 # Miscellaneous
 #

+ 0 - 3
netbox/dcim/apps.py

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

+ 1 - 8
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, VirtualChassis, VCMembership,
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 
 
@@ -680,13 +680,6 @@ class VirtualChassisFilter(django_filters.FilterSet):
         fields = ['domain']
 
 
-class VCMembershipFilter(django_filters.FilterSet):
-
-    class Meta:
-        model = VCMembership
-        fields = ['virtual_chassis', 'device', 'position', 'is_master', 'priority']
-
-
 class ConsoleConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
         method='filter_site',

+ 37 - 82
netbox/dcim/forms.py

@@ -32,7 +32,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, VCMembership, VirtualChassis
+    RackRole, Region, Site, VirtualChassis
 )
 
 DEVICE_BY_PK_RE = '{\d+\}'
@@ -2265,94 +2265,49 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
 # 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 VirtualChassisForm(BootstrapMixin, forms.ModelForm):
 
     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']
-
-
-class VCMembershipCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
-    site = forms.ModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        widget=forms.Select(
-            attrs={'filter-for': 'rack'}
-        )
-    )
-    rack = ChainedModelChoiceField(
-        queryset=Rack.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
-        label='Rack',
-        required=False,
-        widget=APISelect(
-            api_url='/api/dcim/racks/?site_id={{site}}',
-            attrs={'filter-for': 'device', 'nullable': 'true'}
-        )
-    )
-    device = ChainedModelChoiceField(
-        queryset=Device.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('rack', 'rack'),
-        ),
-        label='Device',
-        widget=APISelect(
-            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
-            display_field='display_name'
-        )
-    )
 
-    class Meta:
-        model = VCMembership
-        fields = ['site', 'rack', 'device', 'position', 'priority']
+# class VCAddMemberForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
+#     site = forms.ModelChoiceField(
+#         queryset=Site.objects.all(),
+#         label='Site',
+#         required=False,
+#         widget=forms.Select(
+#             attrs={'filter-for': 'rack'}
+#         )
+#     )
+#     rack = ChainedModelChoiceField(
+#         queryset=Rack.objects.all(),
+#         chains=(
+#             ('site', 'site'),
+#         ),
+#         label='Rack',
+#         required=False,
+#         widget=APISelect(
+#             api_url='/api/dcim/racks/?site_id={{site}}',
+#             attrs={'filter-for': 'device', 'nullable': 'true'}
+#         )
+#     )
+#     device = ChainedModelChoiceField(
+#         queryset=Device.objects.all(),
+#         chains=(
+#             ('site', 'site'),
+#             ('rack', 'rack'),
+#         ),
+#         label='Device',
+#         widget=APISelect(
+#             api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
+#             display_field='display_name'
+#         )
+#     )
+#     vc_position = forms.IntegerField(label='Position')
+#     vc_priority = forms.IntegerField(required=False, label='Priority')

+ 15 - 18
netbox/dcim/migrations/0052_virtual_chassis.py

@@ -15,33 +15,30 @@ class Migration(migrations.Migration):
 
     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)),
+                ('master', models.OneToOneField(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')),
             ],
         ),
         migrations.AddField(
-            model_name='vcmembership',
+            model_name='device',
             name='virtual_chassis',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='dcim.VirtualChassis'),
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'),
+        ),
+        migrations.AddField(
+            model_name='device',
+            name='vc_position',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
+        ),
+        migrations.AddField(
+            model_name='device',
+            name='vc_priority',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
         ),
         migrations.AlterUniqueTogether(
-            name='vcmembership',
-            unique_together=set([('virtual_chassis', 'position')]),
+            name='device',
+            unique_together=set([('virtual_chassis', 'vc_position'), ('rack', 'position', 'face')]),
         ),
     ]

+ 27 - 66
netbox/dcim/models.py

@@ -867,6 +867,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         blank=True,
         null=True
     )
+    virtual_chassis = models.ForeignKey(
+        to='VirtualChassis',
+        on_delete=models.SET_NULL,
+        related_name='members',
+        blank=True,
+        null=True
+    )
+    vc_position = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MaxValueValidator(255)]
+    )
+    vc_priority = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MaxValueValidator(255)]
+    )
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     images = GenericRelation(ImageAttachment)
@@ -880,7 +897,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 
     class Meta:
         ordering = ['name']
-        unique_together = ['rack', 'position', 'face']
+        unique_together = [
+            ['rack', 'position', 'face'],
+            ['virtual_chassis', 'vc_position'],
+        ]
         permissions = (
             ('napalm_read', 'Read-only access to devices via NAPALM'),
             ('napalm_write', 'Read/write access to devices via NAPALM'),
@@ -1080,13 +1100,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             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
@@ -1593,70 +1606,18 @@ class VirtualChassis(models.Model):
     """
     A collection of Devices which operate with a shared control plane (e.g. a switch stack).
     """
+    master = models.OneToOneField(
+        to='Device',
+        on_delete=models.PROTECT,
+        related_name='vc_master_for'
+    )
     domain = models.CharField(
         max_length=30,
         blank=True
     )
 
     def __str__(self):
-        return self.master.name
+        return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
 
     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)
-                )

+ 0 - 17
netbox/dcim/signals.py

@@ -1,17 +0,0 @@
-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()

+ 225 - 225
netbox/dcim/tests/test_api.py

@@ -10,7 +10,7 @@ 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, VCMembership, VirtualChassis,
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from users.models import Token
@@ -2937,227 +2937,227 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase):
         self.assertEqual(VirtualChassis.objects.count(), 2)
 
 
-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_create_vcmembership_bulk(self):
-
-        vc3 = VirtualChassis.objects.create()
-
-        data = [
-            # Set the master of an existing VC
-            {
-                'virtual_chassis': vc3.pk,
-                'device': self.device7.pk,
-                'position': 1,
-                'is_master': True,
-                'priority': 10,
-            },
-            # Add a non-master member to a VC
-            {
-                'virtual_chassis': vc3.pk,
-                'device': self.device8.pk,
-                'position': 2,
-                'is_master': False,
-                'priority': 20,
-            },
-            # Force the creation of a new VC
-            {
-                'device': self.device9.pk,
-                'position': 1,
-                'is_master': True,
-                'priority': 10,
-            },
-        ]
-
-        url = reverse('dcim-api:vcmembership-list')
-        response = self.client.post(url, data, format='json', **self.header)
-
-        self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(VirtualChassis.objects.count(), 4)
-        self.assertEqual(VCMembership.objects.count(), 9)
-        self.assertEqual(response.data[0]['device'], data[0]['device'])
-        self.assertEqual(response.data[1]['device'], data[1]['device'])
-        self.assertEqual(response.data[2]['device'], data[2]['device'])
-
-    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)
+# 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_create_vcmembership_bulk(self):
+#
+#         vc3 = VirtualChassis.objects.create()
+#
+#         data = [
+#             # Set the master of an existing VC
+#             {
+#                 'virtual_chassis': vc3.pk,
+#                 'device': self.device7.pk,
+#                 'position': 1,
+#                 'is_master': True,
+#                 'priority': 10,
+#             },
+#             # Add a non-master member to a VC
+#             {
+#                 'virtual_chassis': vc3.pk,
+#                 'device': self.device8.pk,
+#                 'position': 2,
+#                 'is_master': False,
+#                 'priority': 20,
+#             },
+#             # Force the creation of a new VC
+#             {
+#                 'device': self.device9.pk,
+#                 'position': 1,
+#                 'is_master': True,
+#                 'priority': 10,
+#             },
+#         ]
+#
+#         url = reverse('dcim-api:vcmembership-list')
+#         response = self.client.post(url, data, format='json', **self.header)
+#
+#         self.assertHttpStatus(response, status.HTTP_201_CREATED)
+#         self.assertEqual(VirtualChassis.objects.count(), 4)
+#         self.assertEqual(VCMembership.objects.count(), 9)
+#         self.assertEqual(response.data[0]['device'], data[0]['device'])
+#         self.assertEqual(response.data[1]['device'], data[1]['device'])
+#         self.assertEqual(response.data[2]['device'], data[2]['device'])
+#
+#     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)

+ 1 - 5
netbox/dcim/urls.py

@@ -220,10 +220,6 @@ urlpatterns = [
     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'),
-    url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
-
-    # 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'),
+    # url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
 
 ]

+ 72 - 115
netbox/dcim/views.py

@@ -7,7 +7,7 @@ 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.forms import ModelChoiceField, ModelForm, modelformset_factory
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
@@ -33,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, VCMembership, VirtualChassis,
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 
 
@@ -861,8 +861,11 @@ class DeviceView(View):
             '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')
+        # VirtualChassis members
+        if device.virtual_chassis is not None:
+            vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis)
+        else:
+            vc_members = []
 
         # Console ports
         console_ports = natsorted(
@@ -922,7 +925,7 @@ class DeviceView(View):
             'device_bays': device_bays,
             'services': services,
             'secrets': secrets,
-            'vc_memberships': vc_memberships,
+            'vc_members': vc_members,
             'related_devices': related_devices,
             'show_graphs': show_graphs,
         })
@@ -2039,12 +2042,38 @@ class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = InventoryItem
 
 
+class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_inventoryitem'
+    model_form = forms.InventoryItemCSVForm
+    table = tables.InventoryItemTable
+    default_return_url = 'dcim:inventoryitem_list'
+
+
+class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_inventoryitem'
+    cls = InventoryItem
+    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    filter = filters.InventoryItemFilter
+    table = tables.InventoryItemTable
+    form = forms.InventoryItemBulkEditForm
+    default_return_url = 'dcim:inventoryitem_list'
+
+
+class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_inventoryitem'
+    cls = InventoryItem
+    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    table = tables.InventoryItemTable
+    template_name = 'dcim/inventoryitem_bulk_delete.html'
+    default_return_url = 'dcim:inventoryitem_list'
+
+
 #
 # Virtual chassis
 #
 
 class VirtualChassisListView(ObjectListView):
-    queryset = VirtualChassis.objects.annotate(member_count=Count('memberships'))
+    queryset = VirtualChassis.objects.annotate(member_count=Count('members'))
     table = tables.VirtualChassisTable
     template_name = 'dcim/virtualchassis_list.html'
 
@@ -2063,39 +2092,31 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
             messages.warning(request, "No devices were selected.")
             return redirect('dcim:device_list')
 
-        # 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))
+        # TODO: Error if any of the devices already belong to a VC
 
-            class Meta:
-                model = VCMembership
-                fields = ['device', 'position', 'priority']
-
-        VCMembershipFormSet = modelformset_factory(model=VCMembership, form=_VCMembershipForm, extra=len(device_list))
+        VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
 
         if '_create' in request.POST:
 
-            vc_form = forms.VirtualChassisCreateForm(device_list, request.POST)
-            formset = VCMembershipFormSet(request.POST)
+            vc_form = forms.VirtualChassisForm(request.POST)
+            formset = VCMemberFormSet(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()
+                    devices = formset.save(commit=False)
+                    for device in devices:
+                        device.virtual_chassis = virtual_chassis
+                        device.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)
+            vc_form = forms.VirtualChassisForm()
+            vc_form.fields['master'].queryset = Device.objects.filter(pk__in=device_list)
+            formset = VCMemberFormSet(queryset=Device.objects.filter(pk__in=device_list))
 
-        return render(request, 'dcim/virtualchassis_add.html', {
+        return render(request, 'dcim/virtualchassis_edit.html', {
             'pk_form': pk_form,
             'vc_form': vc_form,
             'formset': formset,
@@ -2103,112 +2124,48 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
         })
 
 
-class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView):
+class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
     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'
-
-
-class VirtualChassisAddMemberView(GetReturnURLMixin, View):
-    """
-    Create a new VCMembership tying a Device to the VirtualChassis.
-    """
-    template_name = 'utilities/obj_edit.html'
 
     def get(self, request, pk):
 
         virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
-        obj = VCMembership(virtual_chassis=virtual_chassis)
+        VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
 
-        initial_data = {k: request.GET[k] for k in request.GET}
-        form = forms.VCMembershipCreateForm(instance=obj, initial=initial_data)
+        vc_form = forms.VirtualChassisForm(instance=virtual_chassis)
+        vc_form.fields['master'].queryset = virtual_chassis.members.all()
+        formset = VCMemberFormSet(queryset=virtual_chassis.members.all())
 
-        return render(request, self.template_name, {
-            'obj': obj,
-            'obj_type': VCMembership._meta.verbose_name,
-            'form': form,
-            'return_url': self.get_return_url(request, obj),
+        return render(request, 'dcim/virtualchassis_edit.html', {
+            'vc_form': vc_form,
+            'formset': formset,
+            'return_url': self.get_return_url(request, virtual_chassis),
         })
 
     def post(self, request, pk):
 
         virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
-        obj = VCMembership(virtual_chassis=virtual_chassis)
-
-        form = forms.VCMembershipCreateForm(request.POST, instance=obj)
-
-        if form.is_valid():
+        VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0)
 
-            obj = form.save()
+        vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis)
+        vc_form.fields['master'].queryset = virtual_chassis.members.all()
+        formset = VCMemberFormSet(request.POST, queryset=virtual_chassis.members.all())
 
-            msg = 'Added member <a href="{}">{}</a>'.format(obj.device.get_absolute_url(), escape(obj.device))
-            messages.success(request, mark_safe(msg))
-            UserAction.objects.log_create(request.user, obj, msg)
+        if vc_form.is_valid() and formset.is_valid():
 
-            if '_addanother' in request.POST:
-                return redirect(request.get_full_path())
+            vc_form.save()
+            formset.save()
 
-            return_url = form.cleaned_data.get('return_url')
-            if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
-                return redirect(return_url)
-            else:
-                return redirect(self.get_return_url(request, obj))
+            return redirect(vc_form.cleaned_data['master'].get_absolute_url())
 
-        return render(request, self.template_name, {
-            'obj': obj,
-            'obj_type': VCMembership._meta.verbose_name,
-            'form': form,
-            'return_url': self.get_return_url(request, obj),
+        return render(request, 'dcim/virtualchassis_add.html', {
+            'vc_form': vc_form,
+            'formset': formset,
+            'return_url': self.get_return_url(request, virtual_chassis),
         })
 
 
-#
-# 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
-    parent_field = 'device'
-
-    def get_return_url(self, request, obj):
-        return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk})
-
-
-class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
-    permission_required = 'dcim.add_inventoryitem'
-    model_form = forms.InventoryItemCSVForm
-    table = tables.InventoryItemTable
-    default_return_url = 'dcim:inventoryitem_list'
-
-
-class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
-    permission_required = 'dcim.change_inventoryitem'
-    cls = InventoryItem
-    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
-    filter = filters.InventoryItemFilter
-    table = tables.InventoryItemTable
-    form = forms.InventoryItemBulkEditForm
-    default_return_url = 'dcim:inventoryitem_list'
-
-
-class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
-    permission_required = 'dcim.delete_inventoryitem'
-    cls = InventoryItem
-    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
-    table = tables.InventoryItemTable
-    template_name = 'dcim/inventoryitem_bulk_delete.html'
-    default_return_url = 'dcim:inventoryitem_list'
+class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_virtualchassis'
+    model = VirtualChassis
+    default_return_url = 'dcim:device_list'

+ 8 - 10
netbox/templates/dcim/device.html

@@ -98,7 +98,7 @@
                 </tr>
             </table>
         </div>
-        {% if vc_memberships %}
+        {% if vc_members %}
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Virtual Chassis</strong>
@@ -110,24 +110,22 @@
                         <th>Master</th>
                         <th>Priority</th>
                     </tr>
-                    {% for vcm in vc_memberships %}
-                        <tr{% if vcm.device == device %} class="success"{% endif %}>
+                    {% for vc_member in vc_members %}
+                        <tr{% if vc_member.device == device %} class="success"{% endif %}>
                             <td>
-                                <a href="{{ vcm.device.get_absolute_url }}">{{ vcm.device }}</a>
+                                <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</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>{{ vc_member.vc_position }}</td>
+                            <td>{% if device.virtual_chassis.master == vc_member %}<i class="fa fa-check"></i>{% endif %}</td>
+                            <td>{{ vc_member.vc_priority|default:"" }}</td>
                         </tr>
                     {% endfor %}
                 </table>
                 <div class="panel-footer text-right">
-                    {% if perms.dcim.add_vcmembership %}
+                    {% if perms.dcim.change_virtualchassis %}
                         <a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                             <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
                         </a>
-                    {% endif %}
-                    {% 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>

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

@@ -1,56 +0,0 @@
-{% 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 %}

+ 53 - 38
netbox/templates/dcim/virtualchassis_edit.html

@@ -1,44 +1,59 @@
-{% extends 'utilities/obj_edit.html' %}
+{% extends '_base.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>
+    <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 %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% 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 %}
+                                {% for hidden in form.hidden_fields %}
+                                    {{ hidden }}
+                                {% endfor %}
+                                <tr>
+                                    <td>{{ form.instance.name }}</td>
+                                    <td>{{ form.vc_position }}</td>
+                                    <td>{{ form.vc_priority }}</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
             </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 %}