diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6ee666cde..4e287fc29 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1011,14 +1011,6 @@ class Device(CreatedUpdatedModel, CustomFieldModel): raise ValidationError({ 'vc_position': "A device assigned to a virtual chassis must have its position defined." }) - try: - virtual_chassis = VirtualChassis.objects.filter(master=self.pk) - if self.virtual_chassis != virtual_chassis: - raise ValidationError( - "This device has been designated the master of a virtual chassis but is not assigned to it." - ) - except VirtualChassis.DoesNotExist: - pass def save(self, *args, **kwargs): @@ -1078,7 +1070,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): def display_name(self): if self.name: return self.name - elif hasattr(self, 'virtual_chassis') and self.virtual_chassis.master.name: + elif self.virtual_chassis and self.virtual_chassis.master.name: return "{}:{}".format(self.virtual_chassis.master, self.vc_position) elif hasattr(self, 'device_type'): return "{}".format(self.device_type) @@ -1108,10 +1100,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): """ If this Device is a VirtualChassis member, return the VC master. Otherwise, return None. """ - if hasattr(self, 'virtual_chassis'): - return self.virtual_chassis.master - else: - return None + return self.virtual_chassis.master if self.virtual_chassis else None @property def vc_interfaces(self): @@ -1120,7 +1109,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): Device belonging to the same VirtualChassis. """ filter = Q(device=self) - if hasattr(self, 'virtual_chassis') and self.virtual_chassis.master == self: + if self.virtual_chassis and self.virtual_chassis.master == self: filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False) return Interface.objects.filter(filter) @@ -1638,8 +1627,9 @@ class VirtualChassis(models.Model): def clean(self): - # Validate master assignment - if self.master not in self.members.all(): + # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new + # VirtualChassis.) + if self.pk and self.master not in self.members.all(): raise ValidationError({ 'master': "The selected master is not assigned to this virtual chassis." - }) + }) \ No newline at end of file diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index a19563ac7..1e8888e97 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,11 +1,20 @@ from __future__ import unicode_literals -from django.db.models.signals import pre_delete +from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from .models import Device, VirtualChassis +@receiver(post_save, sender=VirtualChassis) +def assign_virtualchassis_master(instance, created, **kwargs): + """ + When a VirtualChassis is created, automatically assign its master device to the VC. + """ + if created: + Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1) + + @receiver(pre_delete, sender=VirtualChassis) def clear_virtualchassis_members(instance, **kwargs): """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 4bda1aed8..5ad3985e1 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2861,9 +2861,69 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase): 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') - self.vc3 = VirtualChassis.objects.create(domain='test-domain-3') + 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(master=self.device1, domain='test-domain-1') + Device.objects.filter(pk=self.device2.pk).update(virtual_chassis=self.vc1, vc_position=2) + Device.objects.filter(pk=self.device3.pk).update(virtual_chassis=self.vc1, vc_position=3) + self.vc2 = VirtualChassis.objects.create(master=self.device4, domain='test-domain-2') + Device.objects.filter(pk=self.device5.pk).update(virtual_chassis=self.vc2, vc_position=2) + Device.objects.filter(pk=self.device6.pk).update(virtual_chassis=self.vc2, vc_position=3) def test_get_virtualchassis(self): @@ -2877,46 +2937,58 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase): url = reverse('dcim-api:virtualchassis-list') response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) + self.assertEqual(response.data['count'], 2) def test_create_virtualchassis(self): data = { - 'domain': 'test-domain-4', + 'master': self.device7.pk, + '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) - self.assertEqual(VirtualChassis.objects.count(), 4) - vc4 = VirtualChassis.objects.get(pk=response.data['id']) - self.assertEqual(vc4.domain, data['domain']) + self.assertEqual(VirtualChassis.objects.count(), 3) + vc3 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc3.master.pk, data['master']) + self.assertEqual(vc3.domain, data['domain']) + + # Verify that the master device was automatically assigned to the VC + self.assertTrue(Device.objects.filter(pk=vc3.master.pk, virtual_chassis=vc3.pk).exists()) def test_create_virtualchassis_bulk(self): data = [ { + 'master': self.device7.pk, + 'domain': 'test-domain-3', + }, + { + 'master': self.device8.pk, 'domain': 'test-domain-4', }, { + 'master': self.device9.pk, 'domain': 'test-domain-5', }, - { - 'domain': 'test-domain-6', - }, ] url = reverse('dcim-api:virtualchassis-list') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VirtualChassis.objects.count(), 6) + self.assertEqual(VirtualChassis.objects.count(), 5) + self.assertEqual(response.data[0]['master'], data[0]['master']) self.assertEqual(response.data[0]['domain'], data[0]['domain']) + self.assertEqual(response.data[1]['master'], data[1]['master']) self.assertEqual(response.data[1]['domain'], data[1]['domain']) + self.assertEqual(response.data[2]['master'], data[2]['master']) self.assertEqual(response.data[2]['domain'], data[2]['domain']) def test_update_virtualchassis(self): data = { + 'master': self.device2.pk, 'domain': 'test-domain-x', } @@ -2924,8 +2996,9 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase): response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(VirtualChassis.objects.count(), 3) + self.assertEqual(VirtualChassis.objects.count(), 2) vc1 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc1.master.pk, data['master']) self.assertEqual(vc1.domain, data['domain']) def test_delete_virtualchassis(self): @@ -2934,230 +3007,10 @@ class VirtualChassisTest(HttpStatusMixin, APITestCase): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(VirtualChassis.objects.count(), 2) + 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_create_vcmembership_bulk(self): -# -# vc3 = VirtualChassis.objects.create() -# -# data = [ -# # Set the master of an existing VC -# { -# 'virtual_chassis': vc3.pk, -# 'device': self.device7.pk, -# 'position': 1, -# 'is_master': True, -# 'priority': 10, -# }, -# # Add a non-master member to a VC -# { -# 'virtual_chassis': vc3.pk, -# 'device': self.device8.pk, -# 'position': 2, -# 'is_master': False, -# 'priority': 20, -# }, -# # Force the creation of a new VC -# { -# 'device': self.device9.pk, -# 'position': 1, -# 'is_master': True, -# 'priority': 10, -# }, -# ] -# -# url = reverse('dcim-api:vcmembership-list') -# response = self.client.post(url, data, format='json', **self.header) -# -# self.assertHttpStatus(response, status.HTTP_201_CREATED) -# self.assertEqual(VirtualChassis.objects.count(), 4) -# self.assertEqual(VCMembership.objects.count(), 9) -# self.assertEqual(response.data[0]['device'], data[0]['device']) -# self.assertEqual(response.data[1]['device'], data[1]['device']) -# self.assertEqual(response.data[2]['device'], data[2]['device']) -# -# def test_update_vcmembership(self): -# -# data = { -# 'virtual_chassis': self.vc2.pk, -# 'device': self.device7.pk, -# 'position': 9, -# 'priority': 90, -# } -# -# url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk}) -# response = self.client.put(url, data, format='json', **self.header) -# -# self.assertHttpStatus(response, status.HTTP_200_OK) -# vcm3 = VCMembership.objects.get(pk=response.data['id']) -# self.assertEqual(vcm3.virtual_chassis.pk, data['virtual_chassis']) -# self.assertEqual(vcm3.device.pk, data['device']) -# self.assertEqual(vcm3.position, data['position']) -# self.assertEqual(vcm3.priority, data['priority']) -# -# def test_delete_vcmembership(self): -# -# url = reverse('dcim-api:vcmembership-detail', kwargs={'pk': self.vcm3.pk}) -# response = self.client.delete(url, **self.header) -# -# self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) -# self.assertEqual(VCMembership.objects.count(), 5) + # Verify that all VC members have had their VC-related fields nullified + for d in [self.device1, self.device2, self.device3]: + self.assertTrue( + Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None) + ) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 7b8cfd3b5..9af67fe97 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -111,7 +111,7 @@