From a4019be28c8c6af7e2b134d6750877bf696f3963 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 Jan 2018 22:47:27 -0500 Subject: [PATCH] Collapsed VCMembership into the Device model (WIP) --- netbox/dcim/api/serializers.py | 61 +-- netbox/dcim/api/urls.py | 1 - netbox/dcim/api/views.py | 28 +- netbox/dcim/apps.py | 3 - netbox/dcim/filters.py | 9 +- netbox/dcim/forms.py | 119 ++--- .../dcim/migrations/0052_virtual_chassis.py | 33 +- netbox/dcim/models.py | 93 ++-- netbox/dcim/signals.py | 17 - netbox/dcim/tests/test_api.py | 450 +++++++++--------- netbox/dcim/urls.py | 6 +- netbox/dcim/views.py | 265 +++++------ netbox/templates/dcim/device.html | 18 +- netbox/templates/dcim/virtualchassis_add.html | 56 --- .../templates/dcim/virtualchassis_edit.html | 91 ++-- 15 files changed, 485 insertions(+), 765 deletions(-) delete mode 100644 netbox/dcim/signals.py delete mode 100644 netbox/templates/dcim/virtualchassis_add.html diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b31cab8dc..b241a0bbd 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -14,7 +14,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, 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'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 91ef531ff..145cb7f09 100644 --- a/netbox/dcim/api/urls.py +++ b/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') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 26af44336..7ea51b870 100644 --- a/netbox/dcim/api/views.py +++ b/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 # diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index ef3158508..fb1f4ee39 100644 --- a/netbox/dcim/apps.py +++ b/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 diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index fab37b221..b1b33be85 100644 --- a/netbox/dcim/filters.py +++ b/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', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f09c649cf..456ca0bc5 100644 --- a/netbox/dcim/forms.py +++ b/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') diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py index db10b2510..334f60ca7 100644 --- a/netbox/dcim/migrations/0052_virtual_chassis.py +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -14,34 +14,31 @@ 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')]), ), ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 7b2671119..6f585e4cf 100644 --- a/netbox/dcim/models.py +++ b/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'), @@ -1079,13 +1099,6 @@ 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): """ @@ -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) - ) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py deleted file mode 100644 index 0e0de8f71..000000000 --- a/netbox/dcim/signals.py +++ /dev/null @@ -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() diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index bc92d1483..4bda1aed8 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 938a88b1b..962217d69 100644 --- a/netbox/dcim/urls.py +++ b/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\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), - - # VC memberships - url(r'^vc-memberships/(?P\d+)/edit/$', views.VCMembershipEditView.as_view(), name='vcmembership_edit'), - url(r'^vc-memberships/(?P\d+)/delete/$', views.VCMembershipDeleteView.as_view(), name='vcmembership_delete'), + # url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 07c367c28..17b090b35 100644 --- a/netbox/dcim/views.py +++ b/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,155 +2042,6 @@ class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = InventoryItem -# -# 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.get('pk') - - if not device_list: - 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)) - - 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' - - -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) - - initial_data = {k: request.GET[k] for k in request.GET} - form = forms.VCMembershipCreateForm(instance=obj, initial=initial_data) - - return render(request, self.template_name, { - 'obj': obj, - 'obj_type': VCMembership._meta.verbose_name, - 'form': form, - 'return_url': self.get_return_url(request, obj), - }) - - 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(): - - obj = form.save() - - msg = 'Added member {}'.format(obj.device.get_absolute_url(), escape(obj.device)) - messages.success(request, mark_safe(msg)) - UserAction.objects.log_create(request.user, obj, msg) - - if '_addanother' in request.POST: - return redirect(request.get_full_path()) - - 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 render(request, self.template_name, { - 'obj': obj, - 'obj_type': VCMembership._meta.verbose_name, - 'form': form, - 'return_url': self.get_return_url(request, obj), - }) - - -# -# 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 @@ -2212,3 +2066,106 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 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('members')) + 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.get('pk') + + if not device_list: + messages.warning(request, "No devices were selected.") + return redirect('dcim:device_list') + + # TODO: Error if any of the devices already belong to a VC + + VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0) + + if '_create' in 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() + 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.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_edit.html', { + 'pk_form': pk_form, + 'vc_form': vc_form, + 'formset': formset, + 'return_url': reverse('dcim:device_list'), + }) + + +class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): + permission_required = 'dcim.change_virtualchassis' + + def get(self, request, pk): + + virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0) + + 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, '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) + VCMemberFormSet = modelformset_factory(model=Device, fields=('vc_position', 'vc_priority'), extra=0) + + 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()) + + if vc_form.is_valid() and formset.is_valid(): + + vc_form.save() + formset.save() + + return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + + return render(request, 'dcim/virtualchassis_add.html', { + 'vc_form': vc_form, + 'formset': formset, + 'return_url': self.get_return_url(request, virtual_chassis), + }) + + +class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_virtualchassis' + model = VirtualChassis + default_return_url = 'dcim:device_list' diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index afe7379ff..7b8cfd3b5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -98,7 +98,7 @@ - {% if vc_memberships %} + {% if vc_members %}
Virtual Chassis @@ -110,24 +110,22 @@ Master Priority - {% for vcm in vc_memberships %} - + {% for vc_member in vc_members %} + - {{ vcm.device }} + {{ vc_member }} - {{ vcm.position }} - {% if vcm.is_master %}{% endif %} - {{ vcm.priority|default:"" }} + {{ vc_member.vc_position }} + {% if device.virtual_chassis.master == vc_member %}{% endif %} + {{ vc_member.vc_priority|default:"" }} {% endfor %}