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

Virtual Chassis Support
This commit is contained in:
Jeremy Stretch 2017-12-18 17:09:53 -05:00 committed by GitHub
commit 02e01b7386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 959 additions and 104 deletions

View File

@ -14,7 +14,7 @@ from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, 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 extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
@ -216,7 +216,7 @@ class RackUnitSerializer(serializers.Serializer):
class RackReservationSerializer(serializers.ModelSerializer): class RackReservationSerializer(serializers.ModelSerializer):
rack = NestedRackSerializer() rack = NestedRackSerializer()
user= NestedUserSerializer() user = NestedUserSerializer()
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer()
class Meta: class Meta:
@ -799,3 +799,61 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = InterfaceConnection model = InterfaceConnection
fields = ['id', 'interface_a', 'interface_b', 'connection_status'] 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

View File

@ -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'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
router.register(r'interface-connections', views.InterfaceConnectionViewSet) router.register(r'interface-connections', views.InterfaceConnectionViewSet)
# Virtual chassis
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
router.register(r'vc-memberships', views.VCMembershipViewSet)
# Miscellaneous # Miscellaneous
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')

View File

@ -3,6 +3,7 @@ from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
@ -15,7 +16,7 @@ from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, 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.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
@ -396,6 +397,37 @@ class InterfaceConnectionViewSet(ModelViewSet):
filter_class = filters.InterfaceConnectionFilter 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 # Miscellaneous
# #

View File

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

View File

@ -17,7 +17,7 @@ from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, 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): def filter_device(self, queryset, name, value):
try: try:
device = Device.objects.select_related('device_type').get(**{name: value}) 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 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: except Device.DoesNotExist:
return queryset.none() return queryset.none()
@ -631,6 +632,13 @@ class InventoryItemFilter(DeviceComponentFilterSet):
fields = ['name', 'part_id', 'serial', 'discovered'] fields = ['name', 'part_id', 'serial', 'discovered']
class VCMembershipFilter(django_filters.FilterSet):
class Meta:
model = VCMembership
fields = ['virtual_chassis']
class ConsoleConnectionFilter(django_filters.FilterSet): class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',

View File

@ -30,7 +30,7 @@ from .models import (
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
RackRole, Region, Site, RackRole, Region, Site, VCMembership, VirtualChassis
) )
from .constants import * from .constants import *
@ -773,26 +773,24 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
# Compile list of choices for primary IPv4 and IPv6 addresses # Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]: for family in [4, 6]:
ip_choices = [(None, '---------')] 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 # Collect interface IPs
interface_ips = IPAddress.objects.select_related('interface').filter( interface_ips = IPAddress.objects.select_related('interface').filter(
family=family, interface__device=self.instance family=family, interface_id__in=interface_ids
) )
if interface_ips: if interface_ips:
ip_choices.append( ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
('Interface IPs', [ ip_choices.append(('Interface IPs', ip_list))
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
])
)
# Collect NAT IPs # Collect NAT IPs
nat_ips = IPAddress.objects.select_related('nat_inside').filter( 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: if nat_ips:
ip_choices.append( ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
('NAT IPs', [ ip_choices.append(('NAT IPs', ip_list))
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
])
)
self.fields['primary_ip{}'.format(family)].choices = ip_choices 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 # 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: class Meta:
model = InventoryItem model = InventoryItem
fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] 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']

View File

@ -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')]),
),
]

View File

@ -923,29 +923,28 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
except DeviceType.DoesNotExist: except DeviceType.DoesNotExist:
pass pass
# Validate primary IPv4 address # Validate primary IP addresses
if self.primary_ip4 and ( vc_interfaces = self.vc_interfaces.all()
self.primary_ip4.interface is None or if self.primary_ip4:
self.primary_ip4.interface.device != self if self.primary_ip4.interface in vc_interfaces:
) and ( pass
self.primary_ip4.nat_inside.interface is None or elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
self.primary_ip4.nat_inside.interface.device != self pass
): else:
raise ValidationError({ raise ValidationError({
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4), 'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
}) self.primary_ip4),
})
# Validate primary IPv6 address if self.primary_ip6:
if self.primary_ip6 and ( if self.primary_ip6.interface in vc_interfaces:
self.primary_ip6.interface is None or pass
self.primary_ip6.interface.device != self elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
) and ( pass
self.primary_ip6.nat_inside.interface is None or else:
self.primary_ip6.nat_inside.interface.device != self raise ValidationError({
): 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
raise ValidationError({ self.primary_ip6),
'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) # 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: 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: else:
return None 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): def get_children(self):
""" """
Return the set of child Devices installed in DeviceBays within this Device. Return the set of child Devices installed in DeviceBays within this Device.
@ -1077,6 +1094,9 @@ class ConsolePort(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
# Used for connections export # Used for connections export
def to_csv(self): def to_csv(self):
return csv_format([ return csv_format([
@ -1118,6 +1138,9 @@ class ConsoleServerPort(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
def clean(self): def clean(self):
# Check that the parent device's DeviceType is a console server # Check that the parent device's DeviceType is a console server
@ -1154,6 +1177,9 @@ class PowerPort(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
# Used for connections export # Used for connections export
def to_csv(self): def to_csv(self):
return csv_format([ return csv_format([
@ -1195,6 +1221,9 @@ class PowerOutlet(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
def clean(self): def clean(self):
# Check that the parent device's DeviceType is a PDU # Check that the parent device's DeviceType is a PDU
@ -1274,6 +1303,9 @@ class Interface(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return self.parent.get_absolute_url()
def clean(self): def clean(self):
# Check that the parent device's DeviceType is a network device # Check that the parent device's DeviceType is a network device
@ -1436,6 +1468,9 @@ class DeviceBay(models.Model):
def __str__(self): def __str__(self):
return '{} - {}'.format(self.device.name, self.name) return '{} - {}'.format(self.device.name, self.name)
def get_absolute_url(self):
return self.device.get_absolute_url()
def clean(self): def clean(self):
# Validate that the parent Device can have DeviceBays # Validate that the parent Device can have DeviceBays
@ -1480,3 +1515,84 @@ class InventoryItem(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
#
# Virtual chassis
#
@python_2_unicode_compatible
class VirtualChassis(models.Model):
"""
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
"""
domain = models.CharField(
max_length=30,
blank=True
)
def __str__(self):
return self.master.name
def get_absolute_url(self):
return self.master.get_absolute_url()
@property
def master(self):
master_vcm = VCMembership.objects.filter(virtual_chassis=self, is_master=True).first()
return master_vcm.device if master_vcm else None
@python_2_unicode_compatible
class VCMembership(models.Model):
"""
An attachment of a physical Device to a VirtualChassis.
"""
virtual_chassis = models.ForeignKey(
to='VirtualChassis',
on_delete=models.CASCADE,
related_name='memberships'
)
device = models.OneToOneField(
to='Device',
on_delete=models.CASCADE,
related_name='vc_membership'
)
position = models.PositiveSmallIntegerField(
validators=[MaxValueValidator(255)]
)
is_master = models.BooleanField(
default=False
)
priority = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[MaxValueValidator(255)]
)
class Meta:
ordering = ['virtual_chassis', 'position']
unique_together = ['virtual_chassis', 'position']
verbose_name = 'VC membership'
def __str__(self):
return self.device.name
def clean(self):
# We have to call this here because it won't be called by VCMembershipForm
self.validate_unique()
# Check for master conflicts
if getattr(self, 'virtual_chassis', None) and self.is_master:
master_conflict = VCMembership.objects.filter(
virtual_chassis=self.virtual_chassis, is_master=True
).exclude(pk=self.pk).first()
if master_conflict:
raise ValidationError(
"{} has already been designated as the master for this virtual chassis. It must be demoted before "
"a new master can be assigned.".format(master_conflict.device)
)

17
netbox/dcim/signals.py Normal file
View File

@ -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()

View File

@ -7,7 +7,7 @@ from utilities.tables import BaseTable, ToggleColumn
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, 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 = """ REGION_LINK = """
@ -111,6 +111,12 @@ UTILIZATION_GRAPH = """
{% utilization_graph value %} {% utilization_graph value %}
""" """
VIRTUALCHASSIS_ACTIONS = """
{% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
# #
# Regions # Regions
@ -524,3 +530,22 @@ class InterfaceConnectionTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface model = Interface
fields = ('device_a', 'interface_a', 'device_b', 'interface_b') 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')

View File

@ -5,12 +5,12 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase 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 ( from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, 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 extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from users.models import Token from users.models import Token
@ -2158,3 +2158,249 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name) 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)

View File

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

View File

@ -6,7 +6,9 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import Count, Q from django.db.models import Count, Q
from django.forms import ModelChoiceField, modelformset_factory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@ -22,8 +24,8 @@ from ipam.models import Prefix, Service, VLAN
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectEditView, ObjectListView,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
@ -32,7 +34,7 @@ from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, 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( device = get_object_or_404(Device.objects.select_related(
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
), pk=pk) ), pk=pk)
# Find virtual chassis memberships
vc_memberships = VCMembership.objects.filter(virtual_chassis=device.virtual_chassis).select_related('device')
# Console ports
console_ports = natsorted( console_ports = natsorted(
ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') 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') cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
# Power ports
power_ports = natsorted( power_ports = natsorted(
PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') 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') 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 device.device_type.interface_ordering
).filter(
device=device
).select_related( ).select_related(
'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit' 'circuit_termination__circuit'
).prefetch_related('ip_addresses') ).prefetch_related('ip_addresses')
# Device bays
device_bays = natsorted( device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name') key=attrgetter('name')
) )
# Services
services = Service.objects.filter(device=device) services = Service.objects.filter(device=device)
# Secrets
secrets = device.secrets.all() secrets = device.secrets.all()
# Find up to ten devices in the same site with the same functional role for quick reference. # 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, 'device_bays': device_bays,
'services': services, 'services': services,
'secrets': secrets, 'secrets': secrets,
'vc_memberships': vc_memberships,
'related_devices': related_devices, 'related_devices': related_devices,
'show_graphs': show_graphs, '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' permission_required = 'dcim.change_consoleport'
model = ConsolePort model = ConsolePort
parent_field = 'device'
model_form = forms.ConsolePortForm model_form = forms.ConsolePortForm
class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleport' permission_required = 'dcim.delete_consoleport'
model = ConsolePort model = ConsolePort
parent_field = 'device'
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 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' permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort model = ConsoleServerPort
parent_field = 'device'
model_form = forms.ConsoleServerPortForm model_form = forms.ConsoleServerPortForm
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleserverport' permission_required = 'dcim.delete_consoleserverport'
model = ConsoleServerPort model = ConsoleServerPort
parent_field = 'device'
class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): 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' permission_required = 'dcim.change_powerport'
model = PowerPort model = PowerPort
parent_field = 'device'
model_form = forms.PowerPortForm model_form = forms.PowerPortForm
class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerport' permission_required = 'dcim.delete_powerport'
model = PowerPort model = PowerPort
parent_field = 'device'
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 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' permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet model = PowerOutlet
parent_field = 'device'
model_form = forms.PowerOutletForm model_form = forms.PowerOutletForm
class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_poweroutlet' permission_required = 'dcim.delete_poweroutlet'
model = PowerOutlet model = PowerOutlet
parent_field = 'device'
class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@ -1478,18 +1490,16 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html' template_name = 'dcim/device_component_add.html'
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_interface' permission_required = 'dcim.change_interface'
model = Interface model = Interface
parent_field = 'device'
model_form = forms.InterfaceForm model_form = forms.InterfaceForm
template_name = 'dcim/interface_edit.html' template_name = 'dcim/interface_edit.html'
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interface' permission_required = 'dcim.delete_interface'
model = Interface model = Interface
parent_field = 'device'
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@ -1533,17 +1543,15 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html' template_name = 'dcim/device_component_add.html'
class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicebay' permission_required = 'dcim.change_devicebay'
model = DeviceBay model = DeviceBay
parent_field = 'device'
model_form = forms.DeviceBayForm model_form = forms.DeviceBayForm
class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicebay' permission_required = 'dcim.delete_devicebay'
model = DeviceBay model = DeviceBay
parent_field = 'device'
@permission_required('dcim.change_devicebay') @permission_required('dcim.change_devicebay')
@ -1811,10 +1819,9 @@ class InterfaceConnectionsListView(ObjectListView):
# Inventory items # Inventory items
# #
class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_inventoryitem' permission_required = 'dcim.change_inventoryitem'
model = InventoryItem model = InventoryItem
parent_field = 'device'
model_form = forms.InventoryItemForm model_form = forms.InventoryItemForm
def alter_obj(self, obj, request, url_args, url_kwargs): def alter_obj(self, obj, request, url_args, url_kwargs):
@ -1823,7 +1830,94 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
return obj return obj
class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_inventoryitem' permission_required = 'dcim.delete_inventoryitem'
model = 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' %}
</div>
</div>
{% endblock %}

View File

@ -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): class BulkComponentCreateView(View):
""" """
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.

View File

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