diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9c827912b..0d9b61964 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 @@ -216,7 +216,7 @@ class RackUnitSerializer(serializers.Serializer): class RackReservationSerializer(serializers.ModelSerializer): rack = NestedRackSerializer() - user= NestedUserSerializer() + user = NestedUserSerializer() tenant = NestedTenantSerializer() class Meta: @@ -799,3 +799,61 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer): class Meta: model = InterfaceConnection fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + + +# +# Virtual chassis +# + +class VirtualChassisSerializer(serializers.ModelSerializer): + + class Meta: + model = VirtualChassis + fields = ['id', 'domain'] + + +class NestedVirtualChassisSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + + class Meta: + model = VirtualChassis + fields = ['id', 'url'] + + +class WritableVirtualChassisSerializer(ValidatedModelSerializer): + + class Meta: + model = VirtualChassis + fields = ['id', 'domain'] + + +# +# Virtual chassis memberships +# + +class VCMembershipSerializer(serializers.ModelSerializer): + virtual_chassis = NestedVirtualChassisSerializer() + device = NestedDeviceSerializer() + + class Meta: + model = VCMembership + fields = ['id', 'virtual_chassis', 'device', 'position', 'is_master', 'priority'] + + +class WritableVCMembershipSerializer(ValidatedModelSerializer): + + class Meta: + model = VCMembership + fields = ['id', 'virtual_chassis', 'device', 'position', 'is_master', 'priority'] + + def validate(self, data): + + # Validate uniqueness of (virtual_chassis, position) + validator = UniqueTogetherValidator(queryset=VCMembership.objects.all(), fields=('virtual_chassis', 'position')) + validator.set_context(self) + validator(data) + + # Enforce model validation + super(WritableVCMembershipSerializer, self).validate(data) + + return data 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..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 @@ -15,7 +16,7 @@ from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis ) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet @@ -396,6 +397,37 @@ class InterfaceConnectionViewSet(ModelViewSet): filter_class = filters.InterfaceConnectionFilter +# +# Virtual chassis +# + +class VirtualChassisViewSet(ModelViewSet): + queryset = VirtualChassis.objects.all() + serializer_class = serializers.VirtualChassisSerializer + write_serializer_class = serializers.WritableVirtualChassisSerializer + + +class VCMembershipViewSet(ModelViewSet): + queryset = VCMembership.objects.select_related('virtual_chassis', 'device') + serializer_class = serializers.VCMembershipSerializer + write_serializer_class = serializers.WritableVCMembershipSerializer + filter_class = filters.VCMembershipFilter + + def create(self, request, *args, **kwargs): + + with transaction.atomic(): + + # Automatically create a new VirtualChassis for new VCMemberships with no VC specified + virtual_chassis = request.data.get('virtual_chassis', None) + is_master = request.data.get('is_master', False) + if not virtual_chassis and is_master: + vc = VirtualChassis() + vc.save() + request.data['virtual_chassis'] = vc.pk + + return super(VCMembershipViewSet, self).create(request, *args, **kwargs) + + # # Miscellaneous # 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..e038da5ca 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, VirtualChassis, VCMembership, ) @@ -577,8 +577,9 @@ class InterfaceFilter(django_filters.FilterSet): def filter_device(self, queryset, name, value): try: device = Device.objects.select_related('device_type').get(**{name: value}) + vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] ordering = device.device_type.interface_ordering - return queryset.filter(device=device).order_naturally(ordering) + return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering) except Device.DoesNotExist: return queryset.none() @@ -631,6 +632,13 @@ class InventoryItemFilter(DeviceComponentFilterSet): fields = ['name', 'part_id', 'serial', 'discovered'] +class VCMembershipFilter(django_filters.FilterSet): + + class Meta: + model = VCMembership + fields = ['virtual_chassis'] + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9d6306d4d..b28a1f118 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, VCMembership, VirtualChassis ) from .constants import * @@ -773,26 +773,24 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: ip_choices = [(None, '---------')] + + # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member + interface_ids = self.instance.vc_interfaces.values('pk') + # Collect interface IPs interface_ips = IPAddress.objects.select_related('interface').filter( - family=family, interface__device=self.instance + family=family, interface_id__in=interface_ids ) if interface_ips: - ip_choices.append( - ('Interface IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips - ]) - ) + ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] + ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs nat_ips = IPAddress.objects.select_related('nat_inside').filter( - family=family, nat_inside__interface__device=self.instance + family=family, nat_inside__interface__in=interface_ids ) if nat_ips: - ip_choices.append( - ('NAT IPs', [ - (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips - ]) - ) + ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] + ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device @@ -2170,3 +2168,61 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: model = InventoryItem fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + + +# +# Virtual chassis +# + +class VirtualChassisForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField(queryset=Device.objects.all()) + + class Meta: + model = VirtualChassis + fields = ['domain'] + + def __init__(self, *args, **kwargs): + super(VirtualChassisForm, self).__init__(*args, **kwargs) + + if self.instance: + vc_memberships = self.instance.memberships.all() + self.fields['master'].queryset = Device.objects.filter(pk__in=[vcm.device_id for vcm in vc_memberships]) + self.initial['master'] = self.instance.master + + def save(self, commit=True): + instance = super(VirtualChassisForm, self).save(commit=commit) + + # Update the master membership if it has been changed + master = self.cleaned_data['master'] + if instance.pk and instance.master != master: + VCMembership.objects.filter(virtual_chassis=self.instance).update(is_master=False) + VCMembership.objects.filter(virtual_chassis=self.instance, device=master).update(is_master=True) + + return instance + + +class DeviceSelectionForm(forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + +class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): + master = forms.ModelChoiceField(queryset=Device.objects.all()) + + class Meta: + model = VirtualChassis + fields = ['master', 'domain'] + + def __init__(self, candidate_pks, *args, **kwargs): + super(VirtualChassisCreateForm, self).__init__(*args, **kwargs) + self.fields['master'].queryset = Device.objects.filter(pk__in=candidate_pks) + + +# +# VC memberships +# + +class VCMembershipForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = VCMembership + fields = ['position', 'priority'] diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py new file mode 100644 index 000000000..db10b2510 --- /dev/null +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-11-27 17:27 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0051_rackreservation_tenant'), + ] + + operations = [ + migrations.CreateModel( + name='VCMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position', models.PositiveSmallIntegerField(validators=[django.core.validators.MaxValueValidator(255)])), + ('is_master', models.BooleanField(default=False)), + ('priority', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)])), + ('device', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vc_membership', to='dcim.Device')), + ], + options={ + 'verbose_name': 'VC membership', + 'ordering': ['virtual_chassis', 'position'], + }, + ), + migrations.CreateModel( + name='VirtualChassis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(blank=True, max_length=30)), + ], + ), + migrations.AddField( + model_name='vcmembership', + name='virtual_chassis', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='dcim.VirtualChassis'), + ), + migrations.AlterUniqueTogether( + name='vcmembership', + unique_together=set([('virtual_chassis', 'position')]), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 3ca67c311..9a86cf8c3 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: @@ -1035,6 +1034,24 @@ class Device(CreatedUpdatedModel, CustomFieldModel): else: return None + @property + def virtual_chassis(self): + try: + return VCMembership.objects.get(device=self).virtual_chassis + except VCMembership.DoesNotExist: + return None + + @property + def vc_interfaces(self): + """ + Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another + Device belonging to the same virtual chassis. + """ + filter = Q(device=self) + if hasattr(self, 'vc_membership') and self.vc_membership.is_master: + filter |= Q(device__vc_membership__virtual_chassis=self.vc_membership.virtual_chassis, mgmt_only=False) + return Interface.objects.filter(filter) + def get_children(self): """ Return the set of child Devices installed in DeviceBays within this Device. @@ -1077,6 +1094,9 @@ class ConsolePort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return csv_format([ @@ -1118,6 +1138,9 @@ class ConsoleServerPort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a console server @@ -1154,6 +1177,9 @@ class PowerPort(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + # Used for connections export def to_csv(self): return csv_format([ @@ -1195,6 +1221,9 @@ class PowerOutlet(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a PDU @@ -1274,6 +1303,9 @@ class Interface(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return self.parent.get_absolute_url() + def clean(self): # Check that the parent device's DeviceType is a network device @@ -1436,6 +1468,9 @@ class DeviceBay(models.Model): def __str__(self): return '{} - {}'.format(self.device.name, self.name) + def get_absolute_url(self): + return self.device.get_absolute_url() + def clean(self): # Validate that the parent Device can have DeviceBays @@ -1480,3 +1515,84 @@ class InventoryItem(models.Model): def __str__(self): return self.name + + def get_absolute_url(self): + return self.device.get_absolute_url() + + +# +# Virtual chassis +# + +@python_2_unicode_compatible +class VirtualChassis(models.Model): + """ + A collection of Devices which operate with a shared control plane (e.g. a switch stack). + """ + domain = models.CharField( + max_length=30, + blank=True + ) + + def __str__(self): + return self.master.name + + def get_absolute_url(self): + return self.master.get_absolute_url() + + @property + def master(self): + master_vcm = VCMembership.objects.filter(virtual_chassis=self, is_master=True).first() + return master_vcm.device if master_vcm else None + + +@python_2_unicode_compatible +class VCMembership(models.Model): + """ + An attachment of a physical Device to a VirtualChassis. + """ + virtual_chassis = models.ForeignKey( + to='VirtualChassis', + on_delete=models.CASCADE, + related_name='memberships' + ) + device = models.OneToOneField( + to='Device', + on_delete=models.CASCADE, + related_name='vc_membership' + ) + position = models.PositiveSmallIntegerField( + validators=[MaxValueValidator(255)] + ) + is_master = models.BooleanField( + default=False + ) + priority = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MaxValueValidator(255)] + ) + + class Meta: + ordering = ['virtual_chassis', 'position'] + unique_together = ['virtual_chassis', 'position'] + verbose_name = 'VC membership' + + def __str__(self): + return self.device.name + + def clean(self): + + # We have to call this here because it won't be called by VCMembershipForm + self.validate_unique() + + # Check for master conflicts + if getattr(self, 'virtual_chassis', None) and self.is_master: + master_conflict = VCMembership.objects.filter( + virtual_chassis=self.virtual_chassis, is_master=True + ).exclude(pk=self.pk).first() + if master_conflict: + raise ValidationError( + "{} has already been designated as the master for this virtual chassis. It must be demoted before " + "a new master can be assigned.".format(master_conflict.device) + ) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py new file mode 100644 index 000000000..0e0de8f71 --- /dev/null +++ b/netbox/dcim/signals.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals + +from django.db.models.signals import post_delete +from django.dispatch import receiver + +from .models import VCMembership + + +@receiver(post_delete, sender=VCMembership) +def delete_empty_vc(instance, **kwargs): + """ + When the last VCMembership of a VirtualChassis has been deleted, delete the VirtualChassis as well. + """ + pass + # virtual_chassis = instance.virtual_chassis + # if not VCMembership.objects.filter(virtual_chassis=virtual_chassis).exists(): + # virtual_chassis.delete() diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index d238372e8..c416f3c70 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 @@ -524,3 +530,22 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + + +# +# Virtual chassis +# + +class VirtualChassisTable(BaseTable): + pk = ToggleColumn() + master = tables.LinkColumn() + member_count = tables.Column(verbose_name='Members') + actions = tables.TemplateColumn( + template_code=VIRTUALCHASSIS_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = VirtualChassis + fields = ('pk', 'master', 'domain', 'member_count', 'actions') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index f3529a28f..2d096244f 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) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a15774569..10f3aafde 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -207,4 +207,14 @@ urlpatterns = [ url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), + # Virtual chassis + url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), + url(r'^virtual-chassis/(?P\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 0640885de..4e9cdf521 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 @@ -22,8 +24,8 @@ from ipam.models import Prefix, Service, VLAN from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, + ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -32,7 +34,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VCMembership, VirtualChassis, ) @@ -807,27 +809,44 @@ class DeviceView(View): device = get_object_or_404(Device.objects.select_related( 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' ), pk=pk) + + # Find virtual chassis memberships + vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device') + + # Console ports console_ports = natsorted( ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') ) + + # Console server ports cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + + # Power ports power_ports = natsorted( PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') ) + + # Power outlets power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') - interfaces = Interface.objects.order_naturally( + + # Interfaces + interfaces = device.vc_interfaces.order_naturally( device.device_type.interface_ordering - ).filter( - device=device ).select_related( 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', 'circuit_termination__circuit' ).prefetch_related('ip_addresses') + + # Device bays device_bays = natsorted( DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), key=attrgetter('name') ) + + # Services services = Service.objects.filter(device=device) + + # Secrets secrets = device.secrets.all() # Find up to ten devices in the same site with the same functional role for quick reference. @@ -852,6 +871,7 @@ class DeviceView(View): 'device_bays': device_bays, 'services': services, 'secrets': secrets, + 'vc_memberships': vc_memberships, 'related_devices': related_devices, 'show_graphs': show_graphs, }) @@ -1074,17 +1094,15 @@ def consoleport_disconnect(request, pk): }) -class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort - parent_field = 'device' model_form = forms.ConsolePortForm -class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleport' model = ConsolePort - parent_field = 'device' class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1194,17 +1212,15 @@ def consoleserverport_disconnect(request, pk): }) -class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort - parent_field = 'device' model_form = forms.ConsoleServerPortForm -class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleserverport' model = ConsoleServerPort - parent_field = 'device' class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1313,17 +1329,15 @@ def powerport_disconnect(request, pk): }) -class PowerPortEditView(PermissionRequiredMixin, ComponentEditView): +class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerport' model = PowerPort - parent_field = 'device' model_form = forms.PowerPortForm -class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerport' model = PowerPort - parent_field = 'device' class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1433,17 +1447,15 @@ def poweroutlet_disconnect(request, pk): }) -class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView): +class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet - parent_field = 'device' model_form = forms.PowerOutletForm -class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_poweroutlet' model = PowerOutlet - parent_field = 'device' class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1478,18 +1490,16 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): +class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' model = Interface - parent_field = 'device' model_form = forms.InterfaceForm template_name = 'dcim/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface - parent_field = 'device' class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1533,17 +1543,15 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): +class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_devicebay' model = DeviceBay - parent_field = 'device' model_form = forms.DeviceBayForm -class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicebay' model = DeviceBay - parent_field = 'device' @permission_required('dcim.change_devicebay') @@ -1811,10 +1819,9 @@ class InterfaceConnectionsListView(ObjectListView): # Inventory items # -class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): +class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem - parent_field = 'device' model_form = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): @@ -1823,7 +1830,94 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): return obj -class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem - parent_field = 'device' + + +# +# Virtual chassis +# + +class VirtualChassisListView(ObjectListView): + queryset = VirtualChassis.objects.annotate(member_count=Count('memberships')) + table = tables.VirtualChassisTable + template_name = 'dcim/virtualchassis_list.html' + + +class VirtualChassisCreateView(PermissionRequiredMixin, View): + permission_required = 'dcim.add_virtualchassis' + + def post(self, request): + + # Get the list of devices being added to a VirtualChassis + pk_form = forms.DeviceSelectionForm(request.POST) + pk_form.full_clean() + device_list = pk_form.cleaned_data['pk'] + + # Generate a custom VCMembershipForm where the device field is limited to only the selected devices + class _VCMembershipForm(forms.VCMembershipForm): + device = ModelChoiceField(queryset=Device.objects.filter(pk__in=device_list)) + + class Meta: + model = VCMembership + fields = ['device', 'position', 'priority'] + + VCMembershipFormSet = modelformset_factory(model=VCMembership, form=_VCMembershipForm, extra=len(device_list)) + + if '_create' in request.POST: + + vc_form = forms.VirtualChassisCreateForm(device_list, request.POST) + formset = VCMembershipFormSet(request.POST) + + if vc_form.is_valid() and formset.is_valid(): + with transaction.atomic(): + virtual_chassis = vc_form.save() + vc_memberships = formset.save(commit=False) + for vcm in vc_memberships: + vcm.virtual_chassis = virtual_chassis + if vcm.device == vc_form.cleaned_data['master']: + vcm.is_master = True + vcm.save() + return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + + else: + + vc_form = forms.VirtualChassisCreateForm(device_list) + initial_data = [{'device': pk, 'position': i} for i, pk in enumerate(device_list, start=1)] + formset = VCMembershipFormSet(queryset=VCMembership.objects.none(), initial=initial_data) + + return render(request, 'dcim/virtualchassis_add.html', { + 'pk_form': pk_form, + 'vc_form': vc_form, + 'formset': formset, + 'return_url': reverse('dcim:device_list'), + }) + + +class VirtualChassisEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_virtualchassis' + model = VirtualChassis + model_form = forms.VirtualChassisForm + template_name = 'dcim/virtualchassis_edit.html' + + +class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_virtualchassis' + model = VirtualChassis + default_return_url = 'dcim:device_list' + + +# +# VC memberships +# + +class VCMembershipEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_vcmembership' + model = VCMembership + model_form = forms.VCMembershipForm + + +class VCMembershipDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_vcmembership' + model = VCMembership diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 549b93465..a1f2576fb 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -98,6 +98,43 @@ + {% if vc_memberships %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vcm in vc_memberships %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vcm.device }} + {{ vcm.position }}{% if vcm.is_master %}{% endif %}{{ vcm.priority|default:"" }}
+ +
+ {% 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/inc/interface.html b/netbox/templates/dcim/inc/interface.html index b0c35a0e9..b48d6ee6f 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 %} @@ -105,16 +107,16 @@ - + {% else %} - + {% endif %} {% endif %} - + {% endif %} @@ -124,7 +126,7 @@ {% else %} - + {% endif %} 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 %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html new file mode 100644 index 000000000..8e9724e17 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -0,0 +1,44 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% 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 %} 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 %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 93aa2220f..ad4966b13 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -803,20 +803,6 @@ class ComponentCreateView(View): }) -class ComponentEditView(ObjectEditView): - parent_field = None - - def get_return_url(self, request, obj): - return getattr(obj, self.parent_field).get_absolute_url() - - -class ComponentDeleteView(ObjectDeleteView): - parent_field = None - - def get_return_url(self, request, obj): - return getattr(obj, self.parent_field).get_absolute_url() - - class BulkComponentCreateView(View): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. 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):