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

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

+
+
+ {% include 'utilities/obj_table.html' %} +
+
+{% endblock %} From 3b801d43bcb1e4959307e5156ce30abd30fe072f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2017 15:59:13 -0500 Subject: [PATCH 02/13] Moved VC master designation to membership model --- netbox/dcim/api/serializers.py | 25 +++++++++++------ netbox/dcim/api/views.py | 20 +++++++++++-- netbox/dcim/apps.py | 3 ++ netbox/dcim/filters.py | 9 +++++- .../dcim/migrations/0052_virtual_chassis.py | 5 ++-- netbox/dcim/models.py | 28 +++++++++---------- netbox/dcim/signals.py | 16 +++++++++++ 7 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 netbox/dcim/signals.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5804480a0..0b51ec609 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -806,12 +806,10 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): # class VirtualChassisSerializer(serializers.ModelSerializer): - site = NestedSiteSerializer() - master = NestedDeviceSerializer() class Meta: model = VirtualChassis - fields = ['id', 'site', 'domain', 'master'] + fields = ['id', 'domain'] class NestedVirtualChassisSerializer(serializers.ModelSerializer): @@ -826,7 +824,7 @@ class WritableVirtualChassisSerializer(ValidatedModelSerializer): class Meta: model = VirtualChassis - fields = ['id', 'site', 'domain', 'master'] + fields = ['id', 'domain'] # @@ -839,12 +837,23 @@ class VCMembershipSerializer(serializers.ModelSerializer): class Meta: model = VCMembership - fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] + fields = ['id', 'virtual_chassis', 'device', 'position', 'is_master', 'priority'] -class WritableVCMembershipSerializer(serializers.ModelSerializer): - virtual_chassis = serializers.PrimaryKeyRelatedField(queryset=VirtualChassis.objects.all(), required=False) +class WritableVCMembershipSerializer(ValidatedModelSerializer): class Meta: model = VCMembership - fields = ['id', 'virtual_chassis', 'device', 'master_enabled', 'position', 'priority'] + 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 diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e782172a2..a3ef98a15 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from collections import OrderedDict from django.conf import settings +from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404 from rest_framework.decorators import detail_route @@ -401,17 +402,30 @@ class InterfaceConnectionViewSet(ModelViewSet): # class VirtualChassisViewSet(ModelViewSet): - queryset = VirtualChassis.objects.select_related('master') + queryset = VirtualChassis.objects.all() serializer_class = serializers.VirtualChassisSerializer write_serializer_class = serializers.WritableVirtualChassisSerializer - # filter_class = filters.VirtualChassisFilter class VCMembershipViewSet(ModelViewSet): queryset = VCMembership.objects.select_related('virtual_chassis', 'device') serializer_class = serializers.VCMembershipSerializer write_serializer_class = serializers.WritableVCMembershipSerializer - # filter_class = filters.VCMembershipFilter + filter_class = filters.VCMembershipFilter + + def create(self, request, *args, **kwargs): + + with transaction.atomic(): + + # Automatically create a new VirtualChassis for new VCMemberships with no VC specified + virtual_chassis = request.data.get('virtual_chassis', None) + is_master = request.data.get('is_master', False) + if not virtual_chassis and is_master: + vc = VirtualChassis() + vc.save() + request.data['virtual_chassis'] = vc.pk + + return super(VCMembershipViewSet, self).create(request, *args, **kwargs) # diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index fb1f4ee39..ef3158508 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -6,3 +6,6 @@ from django.apps import AppConfig class DCIMConfig(AppConfig): name = "dcim" verbose_name = "DCIM" + + def ready(self): + import dcim.signals diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index c7f1992f3..d434871d6 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, + RackReservation, RackRole, Region, Site, VCMembership, ) @@ -631,6 +631,13 @@ class InventoryItemFilter(DeviceComponentFilterSet): fields = ['name', 'part_id', 'serial', 'discovered'] +class VCMembershipFilter(django_filters.FilterSet): + + class Meta: + model = VCMembership + fields = ['virtual_chassis'] + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py index 96f38d5c9..db10b2510 100644 --- a/netbox/dcim/migrations/0052_virtual_chassis.py +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-17 20:39 +# Generated by Django 1.11.6 on 2017-11-27 17:27 from __future__ import unicode_literals import django.core.validators @@ -18,8 +18,8 @@ class Migration(migrations.Migration): name='VCMembership', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('master_enabled', models.BooleanField(default=True)), ('position', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(255)])), + ('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')), ], @@ -33,7 +33,6 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('domain', models.CharField(blank=True, max_length=30)), - ('master', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')), ], ), migrations.AddField( diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2d3857c62..24fe183e6 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1494,21 +1494,10 @@ class VirtualChassis(models.Model): max_length=30, blank=True ) - master = models.OneToOneField( - to='Device', - on_delete=models.PROTECT, - related_name='vc_master_for' - ) def get_absolute_url(self): return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) - def clean(self): - - # Check that the master Device is not already assigned to a VirtualChassis. - if VCMembership.objects.filter(device=self.master).exclude(virtual_chassis=self): - raise ValidationError("The master device is already assigned to a different virtual chassis.") - @python_2_unicode_compatible class VCMembership(models.Model): @@ -1525,12 +1514,12 @@ class VCMembership(models.Model): on_delete=models.CASCADE, related_name='vc_membership' ) - master_enabled = models.BooleanField( - default=True - ) position = models.PositiveSmallIntegerField( validators=[MaxValueValidator(255)] ) + is_master = models.BooleanField( + default=False + ) priority = models.PositiveSmallIntegerField( blank=True, null=True, @@ -1541,3 +1530,14 @@ class VCMembership(models.Model): ordering = ['virtual_chassis', 'position'] unique_together = ['virtual_chassis', 'position'] verbose_name = 'VC membership' + + def clean(self): + + # Check for master conflicts + if self.virtual_chassis and self.is_master: + master_conflict = VCMembership.objects.filter(virtual_chassis=self.virtual_chassis).first() + if master_conflict: + raise ValidationError({ + 'virtual_chassis': "{} 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 new file mode 100644 index 000000000..ca33dd251 --- /dev/null +++ b/netbox/dcim/signals.py @@ -0,0 +1,16 @@ +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. + """ + virtual_chassis = instance.virtual_chassis + if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): + virtual_chassis.delete() From 5f914130233d8eb8040ae422f8f9811b05f49c2b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2017 12:58:36 -0500 Subject: [PATCH 03/13] Added initial UI views for virtual chassis assignment --- netbox/dcim/forms.py | 25 +++++++- netbox/dcim/models.py | 9 ++- netbox/dcim/signals.py | 7 ++- netbox/dcim/urls.py | 2 + netbox/dcim/views.py | 60 ++++++++++++++++++- netbox/templates/dcim/device.html | 33 ++++++++++ netbox/templates/dcim/inc/device_table.html | 5 ++ netbox/templates/dcim/virtualchassis_add.html | 56 +++++++++++++++++ 8 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 netbox/templates/dcim/virtualchassis_add.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d803c4d04..529d9c8d8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -30,7 +30,7 @@ from .models import ( DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Region, Site, VirtualChassis + RackRole, Region, Site, VCMembership, VirtualChassis ) from .constants import * @@ -2181,3 +2181,26 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm): class Meta: model = VirtualChassis fields = ['domain'] + + +class DeviceSelectionForm(forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + +class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField(queryset=Device.objects.all()) + + class Meta: + model = VirtualChassis + fields = ['master', 'domain'] + + def __init__(self, candidate_pks, *args, **kwargs): + super(VirtualChassisCreateForm, self).__init__(*args, **kwargs) + self.fields['master'].queryset = Device.objects.filter(pk__in=candidate_pks) + + +class VCMembershipForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = VCMembership + fields = ['device', 'position', 'priority'] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 24fe183e6..a396251ba 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1030,6 +1030,13 @@ 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 + def get_children(self): """ Return the set of child Devices installed in DeviceBays within this Device. @@ -1534,7 +1541,7 @@ class VCMembership(models.Model): def clean(self): # Check for master conflicts - if self.virtual_chassis and self.is_master: + if getattr(self, 'virtual_chassis', None) and self.is_master: master_conflict = VCMembership.objects.filter(virtual_chassis=self.virtual_chassis).first() if master_conflict: raise ValidationError({ diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index ca33dd251..0e0de8f71 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -11,6 +11,7 @@ def delete_empty_vc(instance, **kwargs): """ When the last VCMembership of a VirtualChassis has been deleted, delete the VirtualChassis as well. """ - virtual_chassis = instance.virtual_chassis - if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): - virtual_chassis.delete() + pass + # virtual_chassis = instance.virtual_chassis + # if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): + # virtual_chassis.delete() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 2cc9ede89..cde30f11f 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -209,6 +209,8 @@ urlpatterns = [ # Virtual chassis url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 156647f30..083f6442b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -6,7 +6,9 @@ from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger +from django.db import transaction from django.db.models import Count, Q +from django.forms import ModelChoiceField, modelformset_factory from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -31,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, VirtualChassis + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis, ) @@ -832,6 +834,9 @@ class DeviceView(View): services = Service.objects.filter(device=device) secrets = device.secrets.all() + # Find virtual chassis memberships + vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + # Find up to ten devices in the same site with the same functional role for quick reference. related_devices = Device.objects.filter( site=device.site, device_role=device.device_role @@ -854,6 +859,7 @@ class DeviceView(View): 'device_bays': device_bays, 'services': services, 'secrets': secrets, + 'vc_memberships': vc_memberships, 'related_devices': related_devices, 'show_graphs': show_graphs, }) @@ -1841,6 +1847,52 @@ class VirtualChassisListView(ObjectListView): template_name = 'dcim/virtualchassis_list.html' +class VirtualChassisCreateView(PermissionRequiredMixin, View): + permission_required = 'dcim.add_virtualchassis' + + def post(self, request): + + # Get the list of devices being added to a VirtualChassis + pk_form = forms.DeviceSelectionForm(request.POST) + pk_form.full_clean() + device_list = pk_form.cleaned_data['pk'] + + # Generate a custom VCMembershipForm where the device field is limited to only the selected devices + class _VCMembershipForm(forms.VCMembershipForm): + device = ModelChoiceField(queryset=Device.objects.filter(pk__in=device_list)) + + 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 @@ -1848,3 +1900,9 @@ class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): def get_return_url(self, request, obj): return reverse('dcim:virtualchassis_list') + + +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 549b93465..dedc8ec54 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -98,6 +98,39 @@ + {% if vc_memberships %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vcm in vc_memberships %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vcm.device }} + {{ vcm.position }}{{ vcm.is_master }}{{ vcm.priority|default:"" }} +
+ {% if perms.dcim.delete_virtualchassis %} + + {% endif %} +
+ {% endif %}
Management diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html index 33f7e93aa..68570fdf3 100644 --- a/netbox/templates/dcim/inc/device_table.html +++ b/netbox/templates/dcim/inc/device_table.html @@ -16,4 +16,9 @@
{% endif %} + {% if perms.dcim.add_virtualchassis %} + + {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html new file mode 100644 index 000000000..9623fcd05 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -0,0 +1,56 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +
+ {% csrf_token %} + {{ pk_form.pk }} + {{ formset.management_form }} +
+
+

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

+ {% if vc_form.non_field_errors %} +
+
Errors
+
+ {{ vc_form.non_field_errors }} +
+
+ {% endif %} +
+
Virtual Chassis
+
+ {% render_form vc_form %} +
+
+
+
Members
+ + + + + + + + + + {% for form in formset %} + + + + + + {% endfor %} + +
DevicePositionPriority
{{ form.device }}{{ form.position }}{{ form.priority }}
+
+
+
+
+
+ + Cancel +
+
+
+{% endblock %} From a85b3aa69f66031a128764b20f2c215cc12838ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 7 Dec 2017 17:05:03 -0500 Subject: [PATCH 04/13] Added a form to edit virtual chassis --- netbox/dcim/forms.py | 20 +++++++++++++++++++ netbox/dcim/models.py | 8 ++++++++ netbox/dcim/views.py | 4 +--- netbox/templates/dcim/device.html | 18 ++++++++++------- .../templates/dcim/virtualchassis_edit.html | 11 ++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 netbox/templates/dcim/virtualchassis_edit.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 529d9c8d8..e5631a04c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2177,11 +2177,31 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): # 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) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index b4f78dacc..ec267c297 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1503,9 +1503,17 @@ class VirtualChassis(models.Model): blank=True ) + def __str__(self): + return self.master.name + def get_absolute_url(self): return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) + @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): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7f17af435..bc90be536 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1895,9 +1895,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_virtualchassis' model = VirtualChassis model_form = forms.VirtualChassisForm - - def get_return_url(self, request, obj): - return reverse('dcim:virtualchassis_list') + template_name = 'dcim/virtualchassis_edit.html' class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index dedc8ec54..a1f2576fb 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -116,19 +116,23 @@ {{ vcm.device }} {{ vcm.position }} - {{ vcm.is_master }} + {% if vcm.is_master %}{% endif %} {{ vcm.priority|default:"" }} - {% endfor %} - {% if perms.dcim.delete_virtualchassis %} -
{% endif %}
diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html new file mode 100644 index 000000000..a2627c0b3 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -0,0 +1,11 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
{{ obj_type|capfirst }}
+
+ {% render_form form %} +
+
+{% endblock %} From da2bff691b20cdc8d51276af1259677ee48916d0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 Dec 2017 12:51:52 -0500 Subject: [PATCH 05/13] Added views for editing/deleting VCMemberships --- netbox/dcim/forms.py | 6 ++- netbox/dcim/models.py | 20 ++++++--- netbox/dcim/urls.py | 4 ++ netbox/dcim/views.py | 19 ++++++++ .../templates/dcim/virtualchassis_edit.html | 43 ++++++++++++++++--- 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e5631a04c..f82787865 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2219,8 +2219,12 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): self.fields['master'].queryset = Device.objects.filter(pk__in=candidate_pks) +# +# VC memberships +# + class VCMembershipForm(BootstrapMixin, forms.ModelForm): class Meta: model = VCMembership - fields = ['device', 'position', 'priority'] + fields = ['position', 'priority'] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index ec267c297..6b912c627 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1507,7 +1507,7 @@ class VirtualChassis(models.Model): return self.master.name def get_absolute_url(self): - return "{}?virtual_chassis={}".format(reverse('dcim:device_list'), self.pk) + return self.master.get_absolute_url() @property def master(self): @@ -1547,13 +1547,21 @@ class VCMembership(models.Model): 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).first() + master_conflict = VCMembership.objects.filter( + virtual_chassis=self.virtual_chassis, is_master=True + ).exclude(pk=self.pk).first() if master_conflict: - raise ValidationError({ - 'virtual_chassis': "{} 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) - }) + 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/urls.py b/netbox/dcim/urls.py index cde30f11f..10f3aafde 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -213,4 +213,8 @@ urlpatterns = [ 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'), + # 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'), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bc90be536..1d20a6b97 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1859,6 +1859,10 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View): 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: @@ -1902,3 +1906,18 @@ class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_virtualchassis' model = VirtualChassis default_return_url = 'dcim:device_list' + + +# +# VC memberships +# + +class VCMembershipEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_vcmembership' + model = VCMembership + model_form = forms.VCMembershipForm + + +class VCMembershipDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_vcmembership' + model = VCMembership diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index a2627c0b3..8e9724e17 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -1,11 +1,44 @@ {% extends 'utilities/obj_edit.html' %} {% load form_helpers %} -{% block form %} -
-
{{ obj_type|capfirst }}
-
- {% render_form form %} +{% block content %} + {{ block.super }} +
+
+

Memberships

+
+ + + + + + + + + {% for vcm in form.instance.memberships.all %} + + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vcm.device }} + {{ vcm.position }}{% if vcm.is_master %}{% endif %}{{ vcm.priority|default:"" }} + {% if perms.dcim.change_vcmembership %} + + Edit + + {% endif %} + {% if perms.dcim.delete_vcmembership %} + + Delete + + {% endif %} +
+
{% endblock %} From 911ce3f047b790dc37f69d99e53b5b9d812ea034 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 15:24:03 -0500 Subject: [PATCH 06/13] Display member interfaces when viewing VC master device --- netbox/dcim/views.py | 32 ++++++++++++++++++------ netbox/templates/dcim/inc/interface.html | 6 +++-- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1d20a6b97..f72c2d71a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -809,31 +809,49 @@ class DeviceView(View): device = get_object_or_404(Device.objects.select_related( 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' ), pk=pk) + + # Find virtual chassis memberships + vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + vc_peer_ids = [vcm.device_id for vcm in vc_memberships] + + # Console ports console_ports = natsorted( ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') ) + + # Console server ports cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + + # Power ports power_ports = natsorted( PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') ) + + # Power outlets power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') + + # Interfaces + interfaces_filter = Q(device=device) + if hasattr(device, 'vc_membership') and device.vc_membership.is_master: + interfaces_filter |= Q(device_id__in=vc_peer_ids, mgmt_only=False) interfaces = Interface.objects.order_naturally( device.device_type.interface_ordering - ).filter( - device=device ).select_related( 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', 'circuit_termination__circuit' - ).prefetch_related('ip_addresses') + ).filter(interfaces_filter).prefetch_related('ip_addresses') + + # Device bays device_bays = natsorted( DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), key=attrgetter('name') ) - services = Service.objects.filter(device=device) - secrets = device.secrets.all() - # Find virtual chassis memberships - vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + # Services + services = Service.objects.filter(device=device) + + # Secrets + secrets = device.secrets.all() # Find up to ten devices in the same site with the same functional role for quick reference. related_devices = Device.objects.filter( diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index b0c35a0e9..e43956c98 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,9 +1,11 @@ - {# Checkbox #} + {# Checkbox (exclude VC members) #} {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - + {% if iface.device == device %} + + {% endif %} {% endif %} From 67a30fdf91520d864e4cd45909f1679d5ffc10eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 15:31:35 -0500 Subject: [PATCH 07/13] Added virtual_chassis_id API filter for interfaces --- netbox/dcim/filters.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d434871d6..e63de6f9a 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, VCMembership, + RackReservation, RackRole, Region, Site, VirtualChassis, VCMembership, ) @@ -569,6 +569,11 @@ class InterfaceFilter(django_filters.FilterSet): method='_mac_address', label='MAC address', ) + virtual_chassis_id = django_filters.NumberFilter( + method='_virtual_chassis_id', + name='pk', + label='Virtual chassis (ID)', + ) class Meta: model = Interface @@ -601,6 +606,14 @@ class InterfaceFilter(django_filters.FilterSet): except AddrFormatError: return queryset.none() + def _virtual_chassis_id(self, queryset, name, value): + try: + virtual_chassis = VirtualChassis.objects.get(**{name: value}) + ordering = virtual_chassis.master.device_type.interface_ordering + return queryset.filter(device__vc_membership__virtual_chassis=virtual_chassis).order_naturally(ordering) + except VirtualChassis.DoesNotExist: + return queryset.none() + class DeviceBayFilter(DeviceComponentFilterSet): From 153409d37e25dc7628a53043c567907cd17a8d64 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 15:57:49 -0500 Subject: [PATCH 08/13] Obsoleted ComponentEditView and ComponentDeleteView --- netbox/dcim/models.py | 21 +++++++++++ netbox/dcim/views.py | 46 +++++++++--------------- netbox/templates/dcim/inc/interface.html | 8 ++--- netbox/utilities/views.py | 14 -------- netbox/virtualization/views.py | 10 +++--- 5 files changed, 45 insertions(+), 54 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6b912c627..23b9c0acd 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1084,6 +1084,9 @@ class ConsolePort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return csv_format([ @@ -1125,6 +1128,9 @@ class ConsoleServerPort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a console server @@ -1161,6 +1167,9 @@ class PowerPort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return csv_format([ @@ -1202,6 +1211,9 @@ class PowerOutlet(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a PDU @@ -1281,6 +1293,9 @@ class Interface(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.parent.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a network device @@ -1443,6 +1458,9 @@ class DeviceBay(models.Model): def __str__(self): return '{} - {}'.format(self.device.name, self.name) + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Validate that the parent Device can have DeviceBays @@ -1488,6 +1506,9 @@ class InventoryItem(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # # Virtual chassis diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f72c2d71a..7e635e979 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -24,8 +24,8 @@ from ipam.models import Prefix, Service, VLAN from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, + ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -1098,17 +1098,15 @@ def consoleport_disconnect(request, pk): }) -class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort - parent_field = 'device' model_form = forms.ConsolePortForm -class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleport' model = ConsolePort - parent_field = 'device' class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1218,17 +1216,15 @@ def consoleserverport_disconnect(request, pk): }) -class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort - parent_field = 'device' model_form = forms.ConsoleServerPortForm -class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleserverport' model = ConsoleServerPort - parent_field = 'device' class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1337,17 +1333,15 @@ def powerport_disconnect(request, pk): }) -class PowerPortEditView(PermissionRequiredMixin, ComponentEditView): +class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerport' model = PowerPort - parent_field = 'device' model_form = forms.PowerPortForm -class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerport' model = PowerPort - parent_field = 'device' class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1457,17 +1451,15 @@ def poweroutlet_disconnect(request, pk): }) -class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView): +class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet - parent_field = 'device' model_form = forms.PowerOutletForm -class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_poweroutlet' model = PowerOutlet - parent_field = 'device' class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1502,18 +1494,16 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): +class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' model = Interface - parent_field = 'device' model_form = forms.InterfaceForm template_name = 'dcim/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface - parent_field = 'device' class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1557,17 +1547,15 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): +class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_devicebay' model = DeviceBay - parent_field = 'device' model_form = forms.DeviceBayForm -class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicebay' model = DeviceBay - parent_field = 'device' @permission_required('dcim.change_devicebay') @@ -1835,10 +1823,9 @@ class InterfaceConnectionsListView(ObjectListView): # Inventory items # -class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): +class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem - parent_field = 'device' model_form = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): @@ -1847,10 +1834,9 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): return obj -class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem - parent_field = 'device' # diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index e43956c98..b48d6ee6f 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -107,16 +107,16 @@ - + {% else %} - + {% endif %} {% endif %} - + {% endif %} @@ -126,7 +126,7 @@ {% else %} - + {% endif %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index eda51eff4..40e48c877 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -802,20 +802,6 @@ class ComponentCreateView(View): }) -class ComponentEditView(ObjectEditView): - parent_field = None - - def get_return_url(self, request, obj): - return getattr(obj, self.parent_field).get_absolute_url() - - -class ComponentDeleteView(ObjectDeleteView): - parent_field = None - - def get_return_url(self, request, obj): - return getattr(obj, self.parent_field).get_absolute_url() - - class BulkComponentCreateView(View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4f2981748..6f897bed6 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,8 +11,8 @@ from dcim.models import Device, Interface from dcim.tables import DeviceTable from ipam.models import Service from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, + ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -331,17 +331,15 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'virtualization/virtualmachine_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): +class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' model = Interface - parent_field = 'virtual_machine' model_form = forms.InterfaceForm -class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface - parent_field = 'virtual_machine' class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): From 70d235f99e49fda28e4d29b019a9a2bedf6ad08f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Dec 2017 17:21:43 -0500 Subject: [PATCH 09/13] Added virtual chassis tests --- netbox/dcim/tests/test_api.py | 250 +++++++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index f3529a28f..2cdd197b7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -5,12 +5,12 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from dcim.constants import IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT +from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis, ) from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from users.models import Token @@ -2158,3 +2158,249 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.device1.name) + + +class VirtualChassisTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + self.vc1 = VirtualChassis.objects.create(domain='test-domain-1') + self.vc2 = VirtualChassis.objects.create(domain='test-domain-2') + + def test_get_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['domain'], self.vc1.domain) + + def test_list_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 2) + + def test_create_virtualchassis(self): + + data = { + 'domain': 'test-domain-3', + } + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + vc3 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc3.domain, data['domain']) + + def test_update_virtualchassis(self): + + data = { + 'domain': 'test-domain-x', + } + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(VirtualChassis.objects.count(), 2) + vc1 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc1.domain, data['domain']) + + def test_delete_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(VirtualChassis.objects.count(), 1) + + +class VCMembershipTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site', slug='test-site') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type', slug='test-device-type' + ) + device_role = DeviceRole.objects.create( + name='Test Device Role', slug='test-device-role', color='ff0000' + ) + + # Create 9 member Devices with 12 interfaces each + self.device1 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch1', site=site + ) + self.device2 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch2', site=site + ) + self.device3 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch3', site=site + ) + self.device4 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch4', site=site + ) + self.device5 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch5', site=site + ) + self.device6 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch6', site=site + ) + self.device7 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch7', site=site + ) + self.device8 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch8', site=site + ) + self.device9 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch9', site=site + ) + for i in range(0, 13): + Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + + # Create two VirtualChassis with three members each + self.vc1 = VirtualChassis.objects.create(domain='test-domain-1') + self.vc2 = VirtualChassis.objects.create(domain='test-domain-2') + self.vcm1 = VCMembership.objects.create( + virtual_chassis=self.vc1, device=self.device1, position=1, priority=10, is_master=True + ) + self.vcm2 = VCMembership.objects.create( + virtual_chassis=self.vc1, device=self.device2, position=2, priority=20 + ) + self.vcm3 = VCMembership.objects.create( + virtual_chassis=self.vc1, device=self.device3, position=3, priority=30 + ) + self.vcm4 = VCMembership.objects.create( + virtual_chassis=self.vc2, device=self.device4, position=1, priority=10, is_master=True + ) + self.vcm5 = VCMembership.objects.create( + virtual_chassis=self.vc2, device=self.device5, position=2, priority=20 + ) + self.vcm6 = VCMembership.objects.create( + virtual_chassis=self.vc2, device=self.device6, position=3, priority=30 + ) + + def test_get_vcmembership(self): + + url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['virtual_chassis']['id'], self.vc1.pk) + self.assertEqual(response.data['device']['id'], self.device1.pk) + self.assertEqual(response.data['position'], 1) + self.assertEqual(response.data['is_master'], True) + self.assertEqual(response.data['priority'], 10) + + def test_list_vcmemberships(self): + + url = reverse('dcim-api:vcmembership-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 6) + + def test_create_vcmembership(self): + + url = reverse('dcim-api:vcmembership-list') + + # Try creating the first membership without is_master. This should fail. + data ={ + 'device': self.device7.pk, + 'position': 1, + 'priority': 10, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # Add is_master=True and try again. This should succeed. + data.update({ + 'is_master': True, + }) + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + virtualchassis_id = VirtualChassis.objects.get(pk=response.data['virtual_chassis']).pk + + # Try adding a second member with the same position + data = { + 'virtual_chassis': virtualchassis_id, + 'device': self.device8.pk, + 'position': 1, + 'priority': 20, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # Try adding a second member with is_master=True + data['is_master'] = True + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + + # Add a second member (valid) + del(data['is_master']) + data['position'] = 2 + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + # Add a third member (valid) + data = { + 'virtual_chassis': virtualchassis_id, + 'device': self.device9.pk, + 'position': 3, + 'priority': 30, + } + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + self.assertEqual(VCMembership.objects.count(), 9) + + def test_update_vcmembership(self): + + data = { + 'virtual_chassis': self.vc2.pk, + 'device': self.device7.pk, + 'position': 9, + 'priority': 90, + } + + url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + vcm3 = VCMembership.objects.get(pk=response.data['id']) + self.assertEqual(vcm3.virtual_chassis.pk, data['virtual_chassis']) + self.assertEqual(vcm3.device.pk, data['device']) + self.assertEqual(vcm3.position, data['position']) + self.assertEqual(vcm3.priority, data['priority']) + + def test_delete_vcmembership(self): + + url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(VCMembership.objects.count(), 5) From 4871682dc671276892bb875abf8c95d257f2b054 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:08:46 -0500 Subject: [PATCH 10/13] Allow designating primary IPs assigned to a device's peer VC members --- netbox/dcim/forms.py | 22 ++++++++--------- netbox/dcim/models.py | 56 +++++++++++++++++++++++++------------------ 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f82787865..b28a1f118 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -773,26 +773,24 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: ip_choices = [(None, '---------')] + + # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member + interface_ids = self.instance.vc_interfaces.values('pk') + # Collect interface IPs interface_ips = IPAddress.objects.select_related('interface').filter( - family=family, interface__device=self.instance + family=family, interface_id__in=interface_ids ) if interface_ips: - ip_choices.append( - ('Interface IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips - ]) - ) + ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] + ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs nat_ips = IPAddress.objects.select_related('nat_inside').filter( - family=family, nat_inside__interface__device=self.instance + family=family, nat_inside__interface__in=interface_ids ) if nat_ips: - ip_choices.append( - ('NAT IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips - ]) - ) + ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] + ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 23b9c0acd..12fd2fe83 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -923,29 +923,28 @@ class Device(CreatedUpdatedModel, CustomFieldModel): except DeviceType.DoesNotExist: pass - # Validate primary IPv4 address - if self.primary_ip4 and ( - self.primary_ip4.interface is None or - self.primary_ip4.interface.device != self - ) and ( - self.primary_ip4.nat_inside.interface is None or - self.primary_ip4.nat_inside.interface.device != self - ): - raise ValidationError({ - 'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4), - }) - - # Validate primary IPv6 address - if self.primary_ip6 and ( - self.primary_ip6.interface is None or - self.primary_ip6.interface.device != self - ) and ( - self.primary_ip6.nat_inside.interface is None or - self.primary_ip6.nat_inside.interface.device != self - ): - raise ValidationError({ - 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6), - }) + # Validate primary IP addresses + vc_interfaces = self.vc_interfaces.all() + if self.primary_ip4: + if self.primary_ip4.interface in vc_interfaces: + pass + elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces: + pass + else: + raise ValidationError({ + 'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format( + self.primary_ip4), + }) + if self.primary_ip6: + if self.primary_ip6.interface in vc_interfaces: + pass + elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces: + pass + else: + raise ValidationError({ + 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format( + self.primary_ip6), + }) # A Device can only be assigned to a Cluster in the same Site (or no Site) if self.cluster and self.cluster.site is not None and self.cluster.site != self.site: @@ -1042,6 +1041,17 @@ class Device(CreatedUpdatedModel, CustomFieldModel): except VCMembership.DoesNotExist: return None + @property + def vc_interfaces(self): + """ + Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another + Device belonging to the same virtual chassis. + """ + if hasattr(self, 'vc_membership') and self.vc_membership.is_master: + return Interface.objects.filter(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis) + else: + return self.interfaces.all() + def get_children(self): """ Return the set of child Devices installed in DeviceBays within this Device. From d41f4d2db3a96cfe036262a0a7ac05be46489019 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:22:49 -0500 Subject: [PATCH 11/13] Return all VC member interfaces when filtering for the master device; remove virtual_chassis_id filter --- netbox/dcim/filters.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e63de6f9a..e038da5ca 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -569,11 +569,6 @@ class InterfaceFilter(django_filters.FilterSet): method='_mac_address', label='MAC address', ) - virtual_chassis_id = django_filters.NumberFilter( - method='_virtual_chassis_id', - name='pk', - label='Virtual chassis (ID)', - ) class Meta: model = Interface @@ -582,8 +577,9 @@ class InterfaceFilter(django_filters.FilterSet): def filter_device(self, queryset, name, value): try: device = Device.objects.select_related('device_type').get(**{name: value}) + vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] ordering = device.device_type.interface_ordering - return queryset.filter(device=device).order_naturally(ordering) + return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering) except Device.DoesNotExist: return queryset.none() @@ -606,14 +602,6 @@ class InterfaceFilter(django_filters.FilterSet): except AddrFormatError: return queryset.none() - def _virtual_chassis_id(self, queryset, name, value): - try: - virtual_chassis = VirtualChassis.objects.get(**{name: value}) - ordering = virtual_chassis.master.device_type.interface_ordering - return queryset.filter(device__vc_membership__virtual_chassis=virtual_chassis).order_naturally(ordering) - except VirtualChassis.DoesNotExist: - return queryset.none() - class DeviceBayFilter(DeviceComponentFilterSet): From 022c360964e345e8a825fccd860868293c74af1e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:44:44 -0500 Subject: [PATCH 12/13] Ignore VC member interfaces where mgmt_only=True --- netbox/dcim/models.py | 6 +++--- netbox/dcim/views.py | 8 ++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 12fd2fe83..9a86cf8c3 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1047,10 +1047,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel): Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another Device belonging to the same virtual chassis. """ + filter = Q(device=self) if hasattr(self, 'vc_membership') and self.vc_membership.is_master: - return Interface.objects.filter(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis) - else: - return self.interfaces.all() + filter |= Q(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis, mgmt_only=False) + return Interface.objects.filter(filter) def get_children(self): """ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7e635e979..4e9cdf521 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -812,7 +812,6 @@ class DeviceView(View): # Find virtual chassis memberships vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') - vc_peer_ids = [vcm.device_id for vcm in vc_memberships] # Console ports console_ports = natsorted( @@ -831,15 +830,12 @@ class DeviceView(View): power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') # Interfaces - interfaces_filter = Q(device=device) - if hasattr(device, 'vc_membership') and device.vc_membership.is_master: - interfaces_filter |= Q(device_id__in=vc_peer_ids, mgmt_only=False) - interfaces = Interface.objects.order_naturally( + interfaces = device.vc_interfaces.order_naturally( device.device_type.interface_ordering ).select_related( 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', 'circuit_termination__circuit' - ).filter(interfaces_filter).prefetch_related('ip_addresses') + ).prefetch_related('ip_addresses') # Device bays device_bays = natsorted( From ca7147a0a77c7b359d83f9fadaee8d5254ea1d7f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Dec 2017 16:52:49 -0500 Subject: [PATCH 13/13] PEP8 fixes --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/tests/test_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0b51ec609..0d9b61964 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -216,7 +216,7 @@ class RackUnitSerializer(serializers.Serializer): class RackReservationSerializer(serializers.ModelSerializer): rack = NestedRackSerializer() - user= NestedUserSerializer() + user = NestedUserSerializer() tenant = NestedTenantSerializer() class Meta: diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2cdd197b7..2d096244f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2329,7 +2329,7 @@ class VCMembershipTest(HttpStatusMixin, APITestCase): url = reverse('dcim-api:vcmembership-list') # Try creating the first membership without is_master. This should fail. - data ={ + data = { 'device': self.device7.pk, 'position': 1, 'priority': 10,