mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-18 13:06:30 -06:00
Merge pull request #1782 from digitalocean/99-virtual-chassis
Virtual Chassis Support
This commit is contained in:
commit
02e01b7386
@ -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
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
|
@ -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']
|
||||||
|
47
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
47
netbox/dcim/migrations/0052_virtual_chassis.py
Normal 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')]),
|
||||||
|
),
|
||||||
|
]
|
@ -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
17
netbox/dcim/signals.py
Normal 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()
|
@ -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')
|
||||||
|
@ -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)
|
||||||
|
@ -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'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
56
netbox/templates/dcim/virtualchassis_add.html
Normal file
56
netbox/templates/dcim/virtualchassis_add.html
Normal 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 %}
|
44
netbox/templates/dcim/virtualchassis_edit.html
Normal file
44
netbox/templates/dcim/virtualchassis_edit.html
Normal 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 %}
|
11
netbox/templates/dcim/virtualchassis_list.html
Normal file
11
netbox/templates/dcim/virtualchassis_list.html
Normal 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 %}
|
@ -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.
|
||||||
|
@ -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):
|
||||||
|
Loading…
Reference in New Issue
Block a user