From a0f4d481dce4ef7bb69f63e271e46cd6504f462c Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Thu, 7 May 2020 02:12:08 +0200 Subject: [PATCH 01/38] make single front/rear port work when between panels --- netbox/dcim/models/device_components.py | 30 +++-- netbox/dcim/tests/test_models.py | 162 +++++++++++++++++++++++- 2 files changed, 176 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4005d41a4..d854fdc6e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -115,26 +115,32 @@ class CableTermination(models.Model): # Map a front port to its corresponding rear port if isinstance(termination, FrontPort): - position_stack.append(termination.rear_port_position) # Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance peer_port = RearPort.objects.get(pk=termination.rear_port.pk) + + # Don't use the stack for 1-on-1 ports, they don't have to come in pairs + if peer_port.positions > 1: + position_stack.append(termination.rear_port_position) + return peer_port # Map a rear port/position to its corresponding front port elif isinstance(termination, RearPort): + if termination.positions > 1: + # Can't map to a FrontPort without a position if there are multiple options + if not position_stack: + raise CableTraceSplit(termination) - # Can't map to a FrontPort without a position if there are multiple options - if termination.positions > 1 and not position_stack: - raise CableTraceSplit(termination) + position = position_stack.pop() - # We can assume position 1 if the RearPort has only one position - position = position_stack.pop() if position_stack else 1 - - # Validate the position - if position not in range(1, termination.positions + 1): - raise Exception("Invalid position for {} ({} positions): {})".format( - termination, termination.positions, position - )) + # Validate the position + if position not in range(1, termination.positions + 1): + raise Exception("Invalid position for {} ({} positions): {})".format( + termination, termination.positions, position + )) + else: + # Don't use the stack for 1-on-1 ports, they don't have to come in pairs + position = 1 try: peer_port = FrontPort.objects.get( diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 6db938732..62167b601 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -512,6 +512,11 @@ class CablePathTestCase(TestCase): FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C), )) + # Create a 1-on-1 patch panel + patch_panel = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site) + rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C) + FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C) + def test_direct_connection(self): """ Test a direct connection between two interfaces. @@ -554,17 +559,17 @@ class CablePathTestCase(TestCase): Test a connection which passes through a single front/rear port pair. 1 2 - [Device 1] ----- [Panel 1] ----- [Device 2] + [Device 1] ----- [Panel 5] ----- [Device 2] Iface1 FP1 RP1 Iface1 """ - # Create cables + # Create cables (FP first, RP second) cable1 = Cable( termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) cable1.save() cable2 = Cable( - termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_b=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') ) cable2.save() @@ -592,6 +597,155 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) + # Recreate cable 1 to test creating the cables in reverse order (RP first, FP second) + cable1.save() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + + # Validate connections + self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) + self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertTrue(endpoint_a.connection_status) + self.assertTrue(endpoint_b.connection_status) + + # Delete cable 2 + cable2.delete() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + + # Check that connections have been nullified + self.assertIsNone(endpoint_a.connected_endpoint) + self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_a.connection_status) + self.assertIsNone(endpoint_b.connection_status) + + def test_connection_via_nested_single_rear_port(self): + """ + Test a connection which passes through a single front/rear port pair between two multi-port MUXes. + + Test two connections via patched rear ports: + Device 1 <---> Device 2 + Device 3 <---> Device 4 + + 1 2 + [Device 1] -----------+ +----------- [Device 2] + Iface1 | | Iface1 + FP1 | 3 4 | FP1 + [Panel 1] ----- [Panel 5] ----- [Panel 2] + FP2 | RP1 RP1 FP1 RP1 | FP2 + Iface1 | | Iface1 + [Device 3] -----------+ +----------- [Device 4] + 5 6 + """ + # Create cables (Panel 5 RP first, FP second) + cable1 = Cable( + termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + ) + cable1.save() + cable2 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), + termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') + ) + cable2.save() + cable3 = Cable( + termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1') + ) + cable3.save() + cable4 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'), + termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') + ) + cable4.save() + cable5 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'), + termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1') + ) + cable5.save() + cable6 = Cable( + termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), + termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1') + ) + cable6.save() + + # Retrieve endpoints + endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') + endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') + endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') + endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') + + # Validate connections + self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) + self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) + self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) + self.assertTrue(endpoint_a.connection_status) + self.assertTrue(endpoint_b.connection_status) + self.assertTrue(endpoint_c.connection_status) + self.assertTrue(endpoint_d.connection_status) + + # Delete cable 3 + cable3.delete() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + endpoint_c.refresh_from_db() + endpoint_d.refresh_from_db() + + # Check that connections have been nullified + self.assertIsNone(endpoint_a.connected_endpoint) + self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_c.connected_endpoint) + self.assertIsNone(endpoint_d.connected_endpoint) + self.assertIsNone(endpoint_a.connection_status) + self.assertIsNone(endpoint_b.connection_status) + self.assertIsNone(endpoint_c.connection_status) + self.assertIsNone(endpoint_d.connection_status) + + # Recreate cable 3 to test reverse order (Panel 5 FP first, RP second) + cable3.save() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + endpoint_c.refresh_from_db() + endpoint_d.refresh_from_db() + + # Validate connections + self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) + self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) + self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) + self.assertTrue(endpoint_a.connection_status) + self.assertTrue(endpoint_b.connection_status) + self.assertTrue(endpoint_c.connection_status) + self.assertTrue(endpoint_d.connection_status) + + # Delete cable 4 + cable4.delete() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + endpoint_c.refresh_from_db() + endpoint_d.refresh_from_db() + + # Check that connections have been nullified + self.assertIsNone(endpoint_a.connected_endpoint) + self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_c.connected_endpoint) + self.assertIsNone(endpoint_d.connected_endpoint) + self.assertIsNone(endpoint_a.connection_status) + self.assertIsNone(endpoint_b.connection_status) + self.assertIsNone(endpoint_c.connection_status) + self.assertIsNone(endpoint_d.connection_status) + def test_connections_via_patch(self): """ Test two connections via patched rear ports: From 112dfb865bd748af3dc5d6a9a4e8742d1f9dfb76 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 8 May 2020 01:24:30 +0200 Subject: [PATCH 02/38] Integrate patch panel building into one list --- netbox/dcim/tests/test_models.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 62167b601..ab541e550 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -501,9 +501,12 @@ class CablePathTestCase(TestCase): Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site), + Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site), ) Device.objects.bulk_create(patch_panels) - for patch_panel in patch_panels: + + # Create patch panels with 4 positions + for patch_panel in patch_panels[:4]: rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C) FrontPort.objects.bulk_create(( FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C), @@ -513,9 +516,9 @@ class CablePathTestCase(TestCase): )) # Create a 1-on-1 patch panel - patch_panel = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site) - rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C) - FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C) + for patch_panel in patch_panels[4:]: + rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C) + FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C) def test_direct_connection(self): """ From 3278cc8cc0e26a26fa7b62f160327fdb62a73634 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 8 May 2020 01:28:36 +0200 Subject: [PATCH 03/38] Recreate the model instance instead of re-saving a deleted model Same end result, but easier to read --- netbox/dcim/tests/test_models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ab541e550..3920e990e 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -601,6 +601,10 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_b.connection_status) # Recreate cable 1 to test creating the cables in reverse order (RP first, FP second) + cable1 = Cable( + termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') + ) cable1.save() # Refresh endpoints @@ -712,6 +716,10 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_d.connection_status) # Recreate cable 3 to test reverse order (Panel 5 FP first, RP second) + cable3 = Cable( + termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1') + ) cable3.save() # Refresh endpoints From 56898f7e3797b20f6c9a2c92972b377abc2abe6b Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 8 May 2020 01:42:17 +0200 Subject: [PATCH 04/38] Restore original test_connection_via_single_rear_port test and make separate test for one-on-one panels --- netbox/dcim/tests/test_models.py | 47 +++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 3920e990e..9a816ce30 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -561,6 +561,51 @@ class CablePathTestCase(TestCase): """ Test a connection which passes through a single front/rear port pair. + 1 2 + [Device 1] ----- [Panel 1] ----- [Device 2] + Iface1 FP1 RP1 Iface1 + + TODO: Panel 1's rear port has multiple front ports. Should this even work? + """ + # Create cables (FP first, RP second) + cable1 = Cable( + termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + ) + cable1.save() + cable2 = Cable( + termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') + ) + cable2.save() + + # Retrieve endpoints + endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') + endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') + + # Validate connections + self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) + self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertTrue(endpoint_a.connection_status) + self.assertTrue(endpoint_b.connection_status) + + # Delete cable 1 + cable1.delete() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + + # Check that connections have been nullified + self.assertIsNone(endpoint_a.connected_endpoint) + self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_a.connection_status) + self.assertIsNone(endpoint_b.connection_status) + + def test_connection_via_one_on_one_port(self): + """ + Test a connection which passes through a rear port with exactly one front port. + 1 2 [Device 1] ----- [Panel 5] ----- [Device 2] Iface1 FP1 RP1 Iface1 @@ -630,7 +675,7 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) - def test_connection_via_nested_single_rear_port(self): + def test_connection_via_nested_one_on_one_port(self): """ Test a connection which passes through a single front/rear port pair between two multi-port MUXes. From 1d33d7d2059b4e9216fd4e56aaa2789836469166 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 May 2020 10:44:06 -0400 Subject: [PATCH 05/38] Call full_clean() when saving Cable instances --- netbox/dcim/tests/test_models.py | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 9a816ce30..ceefbe87e 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -572,11 +572,13 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() # Retrieve endpoints @@ -615,11 +617,13 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_b=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() # Retrieve endpoints @@ -650,6 +654,7 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) + cable1.full_clean() cable1.save() # Refresh endpoints @@ -698,31 +703,37 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'), termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') ) + cable4.full_clean() cable4.save() cable5 = Cable( termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'), termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1') ) + cable5.full_clean() cable5.save() cable6 = Cable( termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1') ) + cable6.full_clean() cable6.save() # Retrieve endpoints @@ -765,6 +776,7 @@ class CablePathTestCase(TestCase): termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1') ) + cable3.full_clean() cable3.save() # Refresh endpoints @@ -823,28 +835,33 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') ) + cable4.full_clean() cable4.save() cable5 = Cable( termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2') ) + cable5.full_clean() cable5.save() # Retrieve endpoints @@ -903,43 +920,51 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') ) + cable4.full_clean() cable4.save() cable5 = Cable( termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') ) + cable5.full_clean() cable5.save() cable6 = Cable( termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') ) + cable6.full_clean() cable6.save() cable7 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2') ) + cable7.full_clean() cable7.save() cable8 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') ) + cable8.full_clean() cable8.save() # Retrieve endpoints @@ -999,38 +1024,45 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'), termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1') ) + cable4.full_clean() cable4.save() cable5 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'), termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') ) + cable5.full_clean() cable5.save() cable6 = Cable( termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') ) + cable6.full_clean() cable6.save() cable7 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') ) + cable7.full_clean() cable7.save() # Retrieve endpoints @@ -1080,11 +1112,13 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=CircuitTermination.objects.get(term_side='A') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=CircuitTermination.objects.get(term_side='Z'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() # Retrieve endpoints @@ -1122,21 +1156,25 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_b=CircuitTermination.objects.get(term_side='A') ) + cable2.full_clean() cable2.save() cable3 = Cable( termination_a=CircuitTermination.objects.get(term_side='Z'), termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') ) + cable3.full_clean() cable3.save() cable4 = Cable( termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable4.full_clean() cable4.save() # Retrieve endpoints From 6fc7c6a7d0915756ca7fe5cbedc5a39aff65053e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 May 2020 11:17:09 -0400 Subject: [PATCH 06/38] Update path validation tests for single-position rear port scenarios --- netbox/dcim/tests/test_models.py | 59 ++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ceefbe87e..6ee556df6 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -370,9 +370,13 @@ class CableTestCase(TestCase): self.patch_pannel = Device.objects.create( device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site ) - self.rear_port = RearPort.objects.create(device=self.patch_pannel, name='R1', type=1000) - self.front_port = FrontPort.objects.create( - device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port + self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c') + self.front_port1 = FrontPort.objects.create( + device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1 + ) + self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2) + self.front_port2 = FrontPort.objects.create( + device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1 ) def test_cable_creation(self): @@ -426,7 +430,7 @@ class CableTestCase(TestCase): """ A cable cannot connect a front port to its corresponding rear port """ - cable = Cable(termination_a=self.front_port, termination_b=self.rear_port) + cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1) with self.assertRaises(ValidationError): cable.clean() @@ -439,6 +443,23 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() + def test_multipos_rearport_connections(self): + """ + A RearPort with more than one position can only be connected to another RearPort with the same number of + positions. + """ + with self.assertRaises( + ValidationError, + msg='Connecting a single-position RearPort to a multi-position RearPort should fail' + ): + Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() + + with self.assertRaises( + ValidationError, + msg='Connecting a multi-position RearPort to an Interface should fail' + ): + Cable(termination_a=self.rear_port2, termination_b=self.interface1).full_clean() + def test_cable_cannot_terminate_to_a_virtual_inteface(self): """ A cable cannot terminate to a virtual interface @@ -502,6 +523,7 @@ class CablePathTestCase(TestCase): Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site), + Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site), ) Device.objects.bulk_create(patch_panels) @@ -557,27 +579,26 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) - def test_connection_via_single_rear_port(self): + def test_connection_rear_port_to_interface(self): """ - Test a connection which passes through a single front/rear port pair. + Test a connection which passes through a single front/rear port pair, where the rear port has a single position. 1 2 - [Device 1] ----- [Panel 1] ----- [Device 2] + [Device 1] ----- [Panel 5] ----- [Device 2] Iface1 FP1 RP1 Iface1 - - TODO: Panel 1's rear port has multiple front ports. Should this even work? """ - # Create cables (FP first, RP second) + # Create cables cable1 = Cable( termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) cable1.full_clean() cable1.save() cable2 = Cable( - termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), + termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + self.assertEqual(cable2.termination_a.positions, 1) # Sanity check cable2.full_clean() cable2.save() @@ -682,7 +703,7 @@ class CablePathTestCase(TestCase): def test_connection_via_nested_one_on_one_port(self): """ - Test a connection which passes through a single front/rear port pair between two multi-port MUXes. + Test a connection which passes through a single front/rear port pair between two multi-position rear ports. Test two connections via patched rear ports: Device 1 <---> Device 2 @@ -1147,31 +1168,31 @@ class CablePathTestCase(TestCase): def test_connection_via_patched_circuit(self): """ 1 2 3 4 - [Device 1] ----- [Panel 1] ----- [Circuit] ----- [Panel 2] ----- [Device 2] + [Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2] Iface1 FP1 RP1 A Z RP1 FP1 Iface1 """ # Create cables cable1 = Cable( termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) cable1.full_clean() cable1.save() cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), termination_b=CircuitTermination.objects.get(term_side='A') ) cable2.full_clean() cable2.save() cable3 = Cable( termination_a=CircuitTermination.objects.get(term_side='Z'), - termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') + termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1') ) cable3.full_clean() cable3.save() cable4 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), + termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) cable4.full_clean() From 2fe4656db49d4c08d222351d4a91cd38082254e1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 8 May 2020 11:23:36 -0400 Subject: [PATCH 07/38] Permit connection of a multi-position RearPort to a FrontPort --- netbox/dcim/models/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 1f6478119..ca87bc8d9 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2191,17 +2191,18 @@ class Cable(ChangeLoggedModel): f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" ) - # A RearPort with multiple positions must be connected to a RearPort with an equal number of positions + # A RearPort with multiple positions must be connected to a RearPort with an equal number of positions, or a + # FrontPort for term_a, term_b in [ (self.termination_a, self.termination_b), (self.termination_b, self.termination_a) ]: if isinstance(term_a, RearPort) and term_a.positions > 1: - if not isinstance(term_b, RearPort): + if not isinstance(term_b, (FrontPort, RearPort)): raise ValidationError( - "Rear ports with multiple positions may only be connected to other rear ports" + "Rear ports with multiple positions may only be connected to other pass-through ports" ) - elif term_a.positions != term_b.positions: + if isinstance(term_b, RearPort) and term_a.positions != term_b.positions: raise ValidationError( f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. " f"Both terminations must have the same number of positions." From 2479b8a57f645571747fb17282c7dd3c48d9a23e Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 2 Jun 2020 13:11:35 +0200 Subject: [PATCH 08/38] Validate against is_path_endpoint instead of specific classes, and only when positions > 1 --- netbox/dcim/models/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index ca87bc8d9..6a0b6c087 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2191,20 +2191,20 @@ class Cable(ChangeLoggedModel): f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" ) - # A RearPort with multiple positions must be connected to a RearPort with an equal number of positions, or a - # FrontPort + # Check that a RearPort isn't connected to something silly for term_a, term_b in [ (self.termination_a, self.termination_b), (self.termination_b, self.termination_a) ]: if isinstance(term_a, RearPort) and term_a.positions > 1: - if not isinstance(term_b, (FrontPort, RearPort)): + if term_b.is_path_endpoint: raise ValidationError( "Rear ports with multiple positions may only be connected to other pass-through ports" ) - if isinstance(term_b, RearPort) and term_a.positions != term_b.positions: + if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions: raise ValidationError( - f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. " + f"{term_a} of {term_a.device} has {term_a.positions} position(s) but " + f"{term_b} of {term_b.device} has {term_b.positions}. " f"Both terminations must have the same number of positions." ) From 81a322eaaf796025458b8f84e4655921974a91ab Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 2 Jun 2020 13:13:10 +0200 Subject: [PATCH 09/38] Add position_stack to returned values from trace() --- netbox/dcim/models/device_components.py | 18 ++++++++++-------- netbox/dcim/signals.py | 2 +- netbox/dcim/views.py | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index d854fdc6e..d344546ef 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -93,9 +93,11 @@ class CableTermination(models.Model): def trace(self): """ - Return two items: the traceable portion of a cable path, and the termination points where it splits (if any). - This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where - the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow. + Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and + the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint + along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible + to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses + a FrontPort without traversing a RearPort again. The path is a list representing a complete cable path, with each individual segment represented as a three-tuple: @@ -171,12 +173,12 @@ class CableTermination(models.Model): if not endpoint.cable: path.append((endpoint, None, None)) logger.debug("No cable connected") - return path, None + return path, None, position_stack # Check for loops if endpoint.cable in [segment[1] for segment in path]: logger.debug("Loop detected!") - return path, None + return path, None, position_stack # Record the current segment in the path far_end = endpoint.get_cable_peer() @@ -189,10 +191,10 @@ class CableTermination(models.Model): try: endpoint = get_peer_port(far_end) except CableTraceSplit as e: - return path, e.termination.frontports.all() + return path, e.termination.frontports.all(), position_stack if endpoint is None: - return path, None + return path, None, position_stack def get_cable_peer(self): if self.cable is None: @@ -209,7 +211,7 @@ class CableTermination(models.Model): endpoints = [] # Get the far end of the last path segment - path, split_ends = self.trace() + path, split_ends, position_stack = self.trace() endpoint = path[-1][2] if split_ends is not None: for termination in split_ends: diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index c94ecf61e..1eb6605d1 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -52,7 +52,7 @@ def update_connected_endpoints(instance, **kwargs): # Update any endpoints for this Cable. endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() for endpoint in endpoints: - path, split_ends = endpoint.trace() + path, split_ends, position_stack = endpoint.trace() # Determine overall path status (connected or planned) path_status = True for segment in path: diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d141f93c6..3cf28634b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2057,7 +2057,7 @@ class CableTraceView(PermissionRequiredMixin, View): def get(self, request, model, pk): obj = get_object_or_404(model, pk=pk) - path, split_ends = obj.trace() + path, split_ends, position_stack = obj.trace() total_length = sum( [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] ) From 34ae57dfa361ad7b1e63a28de3b18ad444dce35c Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 2 Jun 2020 13:13:41 +0200 Subject: [PATCH 10/38] Show warning when position stack is not empty after trace --- netbox/dcim/views.py | 1 + netbox/templates/dcim/cable_trace.html | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3cf28634b..68359fc05 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2066,6 +2066,7 @@ class CableTraceView(PermissionRequiredMixin, View): 'obj': obj, 'trace': path, 'split_ends': split_ends, + 'position_stack': position_stack, 'total_length': total_length, }) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 1e7210e9a..64c4a22d9 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -88,6 +88,10 @@ + {% elif position_stack %} +
+

Multiple possible paths end at this point. No connection established!

+
{% else %}

Trace completed!

From 8bd9b460cbad0392832abc92580444820a21e9c1 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 2 Jun 2020 13:14:38 +0200 Subject: [PATCH 11/38] Only complete path when there are not split_ends or position_stack --- netbox/dcim/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 1eb6605d1..7cae1a39c 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -61,7 +61,7 @@ def update_connected_endpoints(instance, **kwargs): break endpoint_a = path[0][0] - endpoint_b = path[-1][2] + endpoint_b = path[-1][2] if not split_ends and not position_stack else None if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False): logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) From 886b59f400da13ada217b7c7b2bec46cae9b44d5 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 2 Jun 2020 13:14:51 +0200 Subject: [PATCH 12/38] Update tests for cables --- netbox/dcim/tests/test_models.py | 86 ++++++++++++-------------------- 1 file changed, 33 insertions(+), 53 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 6ee556df6..f3b890102 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -363,6 +363,7 @@ class CableTestCase(TestCase): ) self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + self.interface3 = Interface.objects.create(device=self.device2, name='eth1') self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2) self.cable.save() @@ -378,6 +379,15 @@ class CableTestCase(TestCase): self.front_port2 = FrontPort.objects.create( device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1 ) + self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3) + self.front_port3 = FrontPort.objects.create( + device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1 + ) + self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') + self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') + self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000) + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000) def test_cable_creation(self): """ @@ -409,7 +419,7 @@ class CableTestCase(TestCase): cable = Cable.objects.filter(pk=self.cable.pk).first() self.assertIsNone(cable) - def test_cable_validates_compatibale_types(self): + def test_cable_validates_compatible_types(self): """ The clean method should have a check to ensure only compatible port types can be connected by a cable """ @@ -443,6 +453,17 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() + def test_singlepos_rearport_connections(self): + """ + A RearPort with one position can be connected to anything as it is just a + cable extender. + """ + # Connecting a single-position RearPort to a multi-position RearPort is ok + Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() + + # Connecting a single-position RearPort to an Interface is ok + Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean() + def test_multipos_rearport_connections(self): """ A RearPort with more than one position can only be connected to another RearPort with the same number of @@ -450,15 +471,18 @@ class CableTestCase(TestCase): """ with self.assertRaises( ValidationError, - msg='Connecting a single-position RearPort to a multi-position RearPort should fail' + msg='Connecting a 2-position RearPort to a 3-position RearPort should fail' ): - Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() + Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean() with self.assertRaises( ValidationError, msg='Connecting a multi-position RearPort to an Interface should fail' ): - Cable(termination_a=self.rear_port2, termination_b=self.interface1).full_clean() + Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean() + + # Connecting a multi-position RearPort to a CircuitTermination should be ok + Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean() def test_cable_cannot_terminate_to_a_virtual_inteface(self): """ @@ -537,7 +561,7 @@ class CablePathTestCase(TestCase): FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C), )) - # Create a 1-on-1 patch panel + # Create 1-on-1 patch panels for patch_panel in patch_panels[4:]: rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C) FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C) @@ -554,6 +578,7 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable.full_clean() cable.save() # Retrieve endpoints @@ -579,52 +604,6 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) - def test_connection_rear_port_to_interface(self): - """ - Test a connection which passes through a single front/rear port pair, where the rear port has a single position. - - 1 2 - [Device 1] ----- [Panel 5] ----- [Device 2] - Iface1 FP1 RP1 Iface1 - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - self.assertEqual(cable2.termination_a.positions, 1) # Sanity check - cable2.full_clean() - cable2.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete cable 1 - cable1.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - def test_connection_via_one_on_one_port(self): """ Test a connection which passes through a rear port with exactly one front port. @@ -641,9 +620,10 @@ class CablePathTestCase(TestCase): cable1.full_clean() cable1.save() cable2 = Cable( - termination_b=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), - termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'), + termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + self.assertEqual(cable2.termination_a.positions, 1) # Sanity check cable2.full_clean() cable2.save() From cafecb091dc11046d3942a00287d0713f0a5fba2 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 16 Jun 2020 21:46:16 +0200 Subject: [PATCH 13/38] Replace temporary comment with proper one --- netbox/dcim/models/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 6a0b6c087..87077ea19 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2191,7 +2191,8 @@ class Cable(ChangeLoggedModel): f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" ) - # Check that a RearPort isn't connected to something silly + # Check that a RearPort with multiple positions isn't connected to an endpoint + # or a RearPort with a different number of positions. for term_a, term_b in [ (self.termination_a, self.termination_b), (self.termination_b, self.termination_a) From 4a11800d9e8bb6aee612ee09c6d1c02f55102c08 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 16 Jun 2020 21:47:10 +0200 Subject: [PATCH 14/38] Better comments --- netbox/dcim/models/device_components.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index d344546ef..b81e395c4 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -120,7 +120,9 @@ class CableTermination(models.Model): # Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance peer_port = RearPort.objects.get(pk=termination.rear_port.pk) - # Don't use the stack for 1-on-1 ports, they don't have to come in pairs + # Don't use the stack for RearPorts with a single position. Only remember the position at + # many-to-one points so we can select the correct FrontPort when we reach the corresponding + # one-to-many point. if peer_port.positions > 1: position_stack.append(termination.rear_port_position) @@ -141,7 +143,7 @@ class CableTermination(models.Model): termination, termination.positions, position )) else: - # Don't use the stack for 1-on-1 ports, they don't have to come in pairs + # Don't use the stack for RearPorts with a single position. The only possible position is 1. position = 1 try: From abaf0daa6eff0e2ed5085c0f057e840b120f56c4 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 16 Jun 2020 21:47:37 +0200 Subject: [PATCH 15/38] Store the front ports on the position_stack so we can provide better feedback to the user --- netbox/dcim/models/device_components.py | 5 +++-- netbox/templates/dcim/cable_trace.html | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index b81e395c4..4fa9c37b5 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -124,7 +124,7 @@ class CableTermination(models.Model): # many-to-one points so we can select the correct FrontPort when we reach the corresponding # one-to-many point. if peer_port.positions > 1: - position_stack.append(termination.rear_port_position) + position_stack.append(termination) return peer_port @@ -135,7 +135,8 @@ class CableTermination(models.Model): if not position_stack: raise CableTraceSplit(termination) - position = position_stack.pop() + front_port = position_stack.pop() + position = front_port.rear_port_position # Validate the position if position not in range(1, termination.positions + 1): diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 64c4a22d9..df484609a 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -90,7 +90,13 @@
{% elif position_stack %}
-

Multiple possible paths end at this point. No connection established!

+

+ {% with last_position=position_stack|last %} + Trace completed, but there is no Front Port corresponding to + {{ last_position.device }} {{ last_position }}.
+ Therefore no end-to-end connection can be established. + {% endwith %} +

{% else %}
From f075339c5f3e7d21ec88d82766a6791d48e5b91c Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 16 Jun 2020 21:48:26 +0200 Subject: [PATCH 16/38] Improve test comments and remove over-enthusiastic tests --- netbox/dcim/tests/test_models.py | 160 ++++++++++++++----------------- 1 file changed, 73 insertions(+), 87 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index f3b890102..c55d099c9 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -383,6 +383,10 @@ class CableTestCase(TestCase): self.front_port3 = FrontPort.objects.create( device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1 ) + self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3) + self.front_port4 = FrontPort.objects.create( + device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 + ) self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') @@ -453,10 +457,21 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() - def test_singlepos_rearport_connections(self): + def test_connection_via_single_position_rearport(self): """ - A RearPort with one position can be connected to anything as it is just a - cable extender. + A RearPort with one position can be connected to anything. + + [CableTermination X]---[RP(pos=1) FP]---[CableTermination Y] + + is allowed anywhere + + [CableTermination X]---[CableTermination Y] + + is allowed. + + A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort + with a different number of positions. RearPorts with a single position on the other hand may be connected + to such CableTerminations. Check that this is indeed allowed. """ # Connecting a single-position RearPort to a multi-position RearPort is ok Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() @@ -464,11 +479,59 @@ class CableTestCase(TestCase): # Connecting a single-position RearPort to an Interface is ok Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean() - def test_multipos_rearport_connections(self): + # Connecting a single-position RearPort to a CircuitTermination is ok + Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean() + + def test_connection_via_multi_position_rearport(self): """ - A RearPort with more than one position can only be connected to another RearPort with the same number of - positions. + A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort + with a different number of positions. + + The following scenario's are allowed (with x>1): + + ~----------+ +---------~ + | | + RP2(pos=x)|---|RP(pos=x) + | | + ~----------+ +---------~ + + ~----------+ +---------~ + | | + RP2(pos=x)|---|RP(pos=1) + | | + ~----------+ +---------~ + + ~----------+ +------------------~ + | | + RP2(pos=x)|---|CircuitTermination + | | + ~----------+ +------------------~ + + These scenarios are NOT allowed (with x>1): + + ~----------+ +----------~ + | | + RP2(pos=x)|---|RP(pos!=x) + | | + ~----------+ +----------~ + + ~----------+ +----------~ + | | + RP2(pos=x)|---|Interface + | | + ~----------+ +----------~ + + These scenarios are tested in this order below. """ + # Connecting a multi-position RearPort to another RearPort with the same number of positions is ok + Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean() + + # Connecting a multi-position RearPort to a single-position RearPort is ok + Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean() + + # Connecting a multi-position RearPort to a CircuitTermination is ok + Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean() + with self.assertRaises( ValidationError, msg='Connecting a 2-position RearPort to a 3-position RearPort should fail' @@ -481,10 +544,7 @@ class CableTestCase(TestCase): ): Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean() - # Connecting a multi-position RearPort to a CircuitTermination should be ok - Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean() - - def test_cable_cannot_terminate_to_a_virtual_inteface(self): + def test_cable_cannot_terminate_to_a_virtual_interface(self): """ A cable cannot terminate to a virtual interface """ @@ -493,7 +553,7 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() - def test_cable_cannot_terminate_to_a_wireless_inteface(self): + def test_cable_cannot_terminate_to_a_wireless_interface(self): """ A cable cannot terminate to a wireless interface """ @@ -604,7 +664,7 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) - def test_connection_via_one_on_one_port(self): + def test_connection_via_single_rear_port(self): """ Test a connection which passes through a rear port with exactly one front port. @@ -650,38 +710,7 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) - # Recreate cable 1 to test creating the cables in reverse order (RP first, FP second) - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') - ) - cable1.full_clean() - cable1.save() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete cable 2 - cable2.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - - def test_connection_via_nested_one_on_one_port(self): + def test_connections_via_nested_single_position_rearport(self): """ Test a connection which passes through a single front/rear port pair between two multi-position rear ports. @@ -772,49 +801,6 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_c.connection_status) self.assertIsNone(endpoint_d.connection_status) - # Recreate cable 3 to test reverse order (Panel 5 FP first, RP second) - cable3 = Cable( - termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1') - ) - cable3.full_clean() - cable3.save() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - endpoint_c.refresh_from_db() - endpoint_d.refresh_from_db() - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) - self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - self.assertTrue(endpoint_c.connection_status) - self.assertTrue(endpoint_d.connection_status) - - # Delete cable 4 - cable4.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - endpoint_c.refresh_from_db() - endpoint_d.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_c.connected_endpoint) - self.assertIsNone(endpoint_d.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - self.assertIsNone(endpoint_c.connection_status) - self.assertIsNone(endpoint_d.connection_status) - def test_connections_via_patch(self): """ Test two connections via patched rear ports: From 3876efe494f5840248d29c0ce6b29ddee243ee44 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Tue, 16 Jun 2020 21:56:46 +0200 Subject: [PATCH 17/38] Fix `is_path_endpoint` flag on CableTermination --- netbox/circuits/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 57d41a994..2bad69789 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -300,6 +300,9 @@ class CircuitTermination(CableTermination): blank=True ) + # Paths do not end on cable terminations, they continue at the other end of the circuit + is_path_endpoint = False + class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] From 715ddc6b02de1725d63d586d085b32d12ecf40e9 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Wed, 17 Jun 2020 17:11:28 +0200 Subject: [PATCH 18/38] Define `is_path_endpoint` and `is_connected_endpoint` separately, as a CableTermination is a possible connected endpoint but not always the end of the path. --- netbox/circuits/models.py | 3 +++ netbox/dcim/models/device_components.py | 14 ++++++++++++++ netbox/dcim/signals.py | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 2bad69789..a51a7039a 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -303,6 +303,9 @@ class CircuitTermination(CableTermination): # Paths do not end on cable terminations, they continue at the other end of the circuit is_path_endpoint = False + # But they are a possible connected endpoint + is_connected_endpoint = True + class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4fa9c37b5..4fb3a9f05 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -86,8 +86,12 @@ class CableTermination(models.Model): object_id_field='termination_b_id' ) + # Whether this class is always an endpoint for cable traces is_path_endpoint = True + # Whether this class can be a connected endpoint + is_connected_endpoint = True + class Meta: abstract = True @@ -895,8 +899,13 @@ class FrontPort(CableTermination, ComponentModel): tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] + + # Whether this class is always an endpoint for cable traces is_path_endpoint = False + # Whether this class can be a connected endpoint + is_connected_endpoint = False + class Meta: ordering = ('device', '_name') unique_together = ( @@ -963,8 +972,13 @@ class RearPort(CableTermination, ComponentModel): tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] + + # Whether this class is always an endpoint for cable traces is_path_endpoint = False + # Whether this class can be a connected endpoint + is_connected_endpoint = False + class Meta: ordering = ('device', '_name') unique_together = ('device', 'name') diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 7cae1a39c..f5bf43c70 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -63,7 +63,7 @@ def update_connected_endpoints(instance, **kwargs): endpoint_a = path[0][0] endpoint_b = path[-1][2] if not split_ends and not position_stack else None - if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False): + if getattr(endpoint_a, 'is_connected_endpoint', False) and getattr(endpoint_b, 'is_connected_endpoint', False): logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) endpoint_a.connected_endpoint = endpoint_b endpoint_a.connection_status = path_status From 1cf0868e30a6ae39a2b4f0c6b1aabb4212a2e694 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Wed, 24 Jun 2020 13:07:54 +0200 Subject: [PATCH 19/38] Bumping version just to test the GitHub Action --- netbox/dcim/api/views.py | 88 ++++++++++++++++++++++------------ netbox/dcim/models/__init__.py | 17 +++++++ 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f70193903..27cdafc8f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -2,7 +2,7 @@ from collections import OrderedDict from django.conf import settings from django.db.models import Count, F -from django.http import HttpResponseForbidden, HttpResponse +from django.http import HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.openapi import Parameter @@ -14,20 +14,17 @@ from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit from dcim import filters -from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim.models import (Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, + DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, + InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, + PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, RackGroup, + RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph from ipam.models import Prefix, VLAN -from utilities.api import ( - get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, -) +from utilities.api import (IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, + get_serializer_for_model) from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers @@ -52,14 +49,20 @@ class CableTraceMixin(object): # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') - x = serializer_a(near_end, context={'request': request}).data + x = serializer_a(near_end, context={ + 'request': request + }).data if cable is not None: - y = serializers.TracedCableSerializer(cable, context={'request': request}).data + y = serializers.TracedCableSerializer(cable, context={ + 'request': request + }).data else: y = None if far_end is not None: serializer_b = get_serializer_for_model(far_end, prefix='Nested') - z = serializer_b(far_end, context={'request': request}).data + z = serializer_b(far_end, context={ + 'request': request + }).data else: z = None @@ -105,7 +108,9 @@ class SiteViewSet(CustomFieldModelViewSet): """ site = get_object_or_404(Site, pk=pk) queryset = Graph.objects.filter(type__model='site') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site}) + serializer = RenderedGraphSerializer(queryset, many=True, context={ + 'graphed_object': site + }) return Response(serializer.data) @@ -148,7 +153,9 @@ class RackViewSet(CustomFieldModelViewSet): filterset_class = filters.RackFilterSet @swagger_auto_schema( - responses={200: serializers.RackUnitSerializer(many=True)}, + responses={ + 200: serializers.RackUnitSerializer(many=True) + }, query_serializer=serializers.RackElevationDetailFilterSerializer ) @action(detail=True) @@ -189,7 +196,9 @@ class RackViewSet(CustomFieldModelViewSet): page = self.paginate_queryset(elevation) if page is not None: - rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) + rack_units = serializers.RackUnitSerializer(page, many=True, context={ + 'request': request + }) return self.get_paginated_response(rack_units.data) @@ -290,10 +299,11 @@ class DeviceBayTemplateViewSet(ModelViewSet): # class DeviceRoleViewSet(ModelViewSet): - queryset = DeviceRole.objects.annotate( - device_count=get_subquery(Device, 'device_role'), - virtualmachine_count=get_subquery(VirtualMachine, 'role') - ) + queryset = DeviceRole.objects.all() + # annotate( + # device_count=Count('devices'), + # virtualmachine_count=Count('virtual_machines') + # ) serializer_class = serializers.DeviceRoleSerializer filterset_class = filters.DeviceRoleFilterSet @@ -349,7 +359,9 @@ class DeviceViewSet(CustomFieldModelViewSet): """ device = get_object_or_404(Device, pk=pk) queryset = Graph.objects.filter(type__model='device') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device}) + serializer = RenderedGraphSerializer(queryset, many=True, context={ + 'graphed_object': device + }) return Response(serializer.data) @@ -362,7 +374,9 @@ class DeviceViewSet(CustomFieldModelViewSet): type=openapi.TYPE_STRING ) ], - responses={'200': serializers.DeviceNAPALMSerializer} + responses={ + '200': serializers.DeviceNAPALMSerializer + } ) @action(detail=True, url_path='napalm') def napalm(self, request, pk): @@ -436,17 +450,25 @@ class DeviceViewSet(CustomFieldModelViewSet): # Validate and execute each specified NAPALM method for method in napalm_methods: if not hasattr(driver, method): - response[method] = {'error': 'Unknown NAPALM method'} + response[method] = { + 'error': 'Unknown NAPALM method' + } continue if not method.startswith('get_'): - response[method] = {'error': 'Only get_* NAPALM methods are supported'} + response[method] = { + 'error': 'Only get_* NAPALM methods are supported' + } continue try: response[method] = getattr(d, method)() except NotImplementedError: - response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} + response[method] = { + 'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver) + } except Exception as e: - response[method] = {'error': 'Method {} failed: {}'.format(method, e)} + response[method] = { + 'error': 'Method {} failed: {}'.format(method, e) + } d.close() return Response(response) @@ -498,7 +520,9 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): """ interface = get_object_or_404(Interface, pk=pk) queryset = Graph.objects.filter(type__model='interface') - serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) + serializer = RenderedGraphSerializer(queryset, many=True, context={ + 'graphed_object': interface + }) return Response(serializer.data) @@ -644,7 +668,9 @@ class ConnectedDeviceViewSet(ViewSet): @swagger_auto_schema( manual_parameters=[_device_param, _interface_param], - responses={'200': serializers.DeviceSerializer} + responses={ + '200': serializers.DeviceSerializer + } ) def list(self, request): @@ -661,4 +687,6 @@ class ConnectedDeviceViewSet(ViewSet): if local_interface is None: return Response() - return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data) + return Response(serializers.DeviceSerializer(local_interface.device, context={ + 'request': request + }).data) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 98cd37c1c..651d2d8de 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1223,6 +1223,23 @@ class DeviceRole(ChangeLoggedModel): self.description, ) + @property + def device_count(self): + device_count_map = dict(Device.objects + .order_by('device_role') + .values_list('device_role') + .annotate(Count('pk'))) + return device_count_map.get(self.pk) + + @property + def virtualmachine_count(self): + from virtualization.models import VirtualMachine + virtualmachine_count_map = dict(VirtualMachine.objects + .order_by('role') + .values_list('role') + .annotate(Count('pk'))) + return virtualmachine_count_map.get(self.pk) + class Platform(ChangeLoggedModel): """ From ed1717f858830defdeb280ab8ad53b155c5b9c7e Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Wed, 24 Jun 2020 13:09:11 +0200 Subject: [PATCH 20/38] Revert "Bumping version just to test the GitHub Action" This reverts commit 1cf0868e --- netbox/dcim/api/views.py | 88 ++++++++++++---------------------- netbox/dcim/models/__init__.py | 17 ------- 2 files changed, 30 insertions(+), 75 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 27cdafc8f..f70193903 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -2,7 +2,7 @@ from collections import OrderedDict from django.conf import settings from django.db.models import Count, F -from django.http import HttpResponse, HttpResponseForbidden +from django.http import HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.openapi import Parameter @@ -14,17 +14,20 @@ from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit from dcim import filters -from dcim.models import (Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, - DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, - InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, - PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis) +from dcim.models import ( + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, +) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph from ipam.models import Prefix, VLAN -from utilities.api import (IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, - get_serializer_for_model) +from utilities.api import ( + get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, +) from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers @@ -49,20 +52,14 @@ class CableTraceMixin(object): # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') - x = serializer_a(near_end, context={ - 'request': request - }).data + x = serializer_a(near_end, context={'request': request}).data if cable is not None: - y = serializers.TracedCableSerializer(cable, context={ - 'request': request - }).data + y = serializers.TracedCableSerializer(cable, context={'request': request}).data else: y = None if far_end is not None: serializer_b = get_serializer_for_model(far_end, prefix='Nested') - z = serializer_b(far_end, context={ - 'request': request - }).data + z = serializer_b(far_end, context={'request': request}).data else: z = None @@ -108,9 +105,7 @@ class SiteViewSet(CustomFieldModelViewSet): """ site = get_object_or_404(Site, pk=pk) queryset = Graph.objects.filter(type__model='site') - serializer = RenderedGraphSerializer(queryset, many=True, context={ - 'graphed_object': site - }) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site}) return Response(serializer.data) @@ -153,9 +148,7 @@ class RackViewSet(CustomFieldModelViewSet): filterset_class = filters.RackFilterSet @swagger_auto_schema( - responses={ - 200: serializers.RackUnitSerializer(many=True) - }, + responses={200: serializers.RackUnitSerializer(many=True)}, query_serializer=serializers.RackElevationDetailFilterSerializer ) @action(detail=True) @@ -196,9 +189,7 @@ class RackViewSet(CustomFieldModelViewSet): page = self.paginate_queryset(elevation) if page is not None: - rack_units = serializers.RackUnitSerializer(page, many=True, context={ - 'request': request - }) + rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) return self.get_paginated_response(rack_units.data) @@ -299,11 +290,10 @@ class DeviceBayTemplateViewSet(ModelViewSet): # class DeviceRoleViewSet(ModelViewSet): - queryset = DeviceRole.objects.all() - # annotate( - # device_count=Count('devices'), - # virtualmachine_count=Count('virtual_machines') - # ) + queryset = DeviceRole.objects.annotate( + device_count=get_subquery(Device, 'device_role'), + virtualmachine_count=get_subquery(VirtualMachine, 'role') + ) serializer_class = serializers.DeviceRoleSerializer filterset_class = filters.DeviceRoleFilterSet @@ -359,9 +349,7 @@ class DeviceViewSet(CustomFieldModelViewSet): """ device = get_object_or_404(Device, pk=pk) queryset = Graph.objects.filter(type__model='device') - serializer = RenderedGraphSerializer(queryset, many=True, context={ - 'graphed_object': device - }) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device}) return Response(serializer.data) @@ -374,9 +362,7 @@ class DeviceViewSet(CustomFieldModelViewSet): type=openapi.TYPE_STRING ) ], - responses={ - '200': serializers.DeviceNAPALMSerializer - } + responses={'200': serializers.DeviceNAPALMSerializer} ) @action(detail=True, url_path='napalm') def napalm(self, request, pk): @@ -450,25 +436,17 @@ class DeviceViewSet(CustomFieldModelViewSet): # Validate and execute each specified NAPALM method for method in napalm_methods: if not hasattr(driver, method): - response[method] = { - 'error': 'Unknown NAPALM method' - } + response[method] = {'error': 'Unknown NAPALM method'} continue if not method.startswith('get_'): - response[method] = { - 'error': 'Only get_* NAPALM methods are supported' - } + response[method] = {'error': 'Only get_* NAPALM methods are supported'} continue try: response[method] = getattr(d, method)() except NotImplementedError: - response[method] = { - 'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver) - } + response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} except Exception as e: - response[method] = { - 'error': 'Method {} failed: {}'.format(method, e) - } + response[method] = {'error': 'Method {} failed: {}'.format(method, e)} d.close() return Response(response) @@ -520,9 +498,7 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): """ interface = get_object_or_404(Interface, pk=pk) queryset = Graph.objects.filter(type__model='interface') - serializer = RenderedGraphSerializer(queryset, many=True, context={ - 'graphed_object': interface - }) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) return Response(serializer.data) @@ -668,9 +644,7 @@ class ConnectedDeviceViewSet(ViewSet): @swagger_auto_schema( manual_parameters=[_device_param, _interface_param], - responses={ - '200': serializers.DeviceSerializer - } + responses={'200': serializers.DeviceSerializer} ) def list(self, request): @@ -687,6 +661,4 @@ class ConnectedDeviceViewSet(ViewSet): if local_interface is None: return Response() - return Response(serializers.DeviceSerializer(local_interface.device, context={ - 'request': request - }).data) + return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 651d2d8de..98cd37c1c 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1223,23 +1223,6 @@ class DeviceRole(ChangeLoggedModel): self.description, ) - @property - def device_count(self): - device_count_map = dict(Device.objects - .order_by('device_role') - .values_list('device_role') - .annotate(Count('pk'))) - return device_count_map.get(self.pk) - - @property - def virtualmachine_count(self): - from virtualization.models import VirtualMachine - virtualmachine_count_map = dict(VirtualMachine.objects - .order_by('role') - .values_list('role') - .annotate(Count('pk'))) - return virtualmachine_count_map.get(self.pk) - class Platform(ChangeLoggedModel): """ From 71afba4d2eb19ec294eac684f854a0fe937f7bef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 17:33:41 -0400 Subject: [PATCH 21/38] Fixes #4791: Update custom script documentation for ObjectVar --- docs/additional-features/custom-scripts.md | 13 +++++++------ netbox/extras/scripts.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 1d84fea24..496e4f7fa 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -156,9 +156,13 @@ direction = ChoiceVar(choices=CHOICES) ### ObjectVar -A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type. +A NetBox object of a particular type, identified by the associated queryset. Most models will utilize the REST API to retrieve available options: Note that any filtering on the queryset in this case has no effect. -* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) +* `queryset` - The base [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) for the model + +### MultiObjectVar + +Similar to `ObjectVar`, but allows for the selection of multiple objects. ### FileVar @@ -222,10 +226,7 @@ class NewBranchScript(Script): ) switch_model = ObjectVar( description="Access switch model", - queryset = DeviceType.objects.filter( - manufacturer__name='Cisco', - model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T'] - ) + queryset = DeviceType.objects.all() ) def run(self, data, commit): diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index e41aa89e3..82e955b8a 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -167,7 +167,7 @@ class ChoiceVar(ScriptVariable): class ObjectVar(ScriptVariable): """ - NetBox object representation. The provided QuerySet will determine the choices available. + A single object within NetBox. """ form_field = DynamicModelChoiceField From 3fdc8e7d3d28a50acbd42ed0ea0965414b1bed56 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 26 Jun 2020 17:25:07 +0200 Subject: [PATCH 22/38] =?UTF-8?q?Replace=20`is=5Fpath=5Fendpoint`=20with?= =?UTF-8?q?=20simple=20`isinstance`=20check=20It=20was=20only=20used=20in?= =?UTF-8?q?=20a=20single=20location=20anyway=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox/circuits/models.py | 3 --- netbox/dcim/models/__init__.py | 3 ++- netbox/dcim/models/device_components.py | 9 --------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index a51a7039a..ca792ae2c 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -300,9 +300,6 @@ class CircuitTermination(CableTermination): blank=True ) - # Paths do not end on cable terminations, they continue at the other end of the circuit - is_path_endpoint = False - # But they are a possible connected endpoint is_connected_endpoint = True diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 87077ea19..0e10e97c8 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2129,6 +2129,7 @@ class Cable(ChangeLoggedModel): return reverse('dcim:cable', args=[self.pk]) def clean(self): + from circuits.models import CircuitTermination # Validate that termination A exists if not hasattr(self, 'termination_a_type'): @@ -2198,7 +2199,7 @@ class Cable(ChangeLoggedModel): (self.termination_b, self.termination_a) ]: if isinstance(term_a, RearPort) and term_a.positions > 1: - if term_b.is_path_endpoint: + if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)): raise ValidationError( "Rear ports with multiple positions may only be connected to other pass-through ports" ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4fb3a9f05..1d6623117 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -86,9 +86,6 @@ class CableTermination(models.Model): object_id_field='termination_b_id' ) - # Whether this class is always an endpoint for cable traces - is_path_endpoint = True - # Whether this class can be a connected endpoint is_connected_endpoint = True @@ -900,9 +897,6 @@ class FrontPort(CableTermination, ComponentModel): csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] - # Whether this class is always an endpoint for cable traces - is_path_endpoint = False - # Whether this class can be a connected endpoint is_connected_endpoint = False @@ -973,9 +967,6 @@ class RearPort(CableTermination, ComponentModel): csv_headers = ['device', 'name', 'type', 'positions', 'description'] - # Whether this class is always an endpoint for cable traces - is_path_endpoint = False - # Whether this class can be a connected endpoint is_connected_endpoint = False From 25926e32f0b70556a3c4990c2cb0cc0c64ba893f Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 26 Jun 2020 17:30:59 +0200 Subject: [PATCH 23/38] =?UTF-8?q?Replace=20`is=5Fconnected=5Fendpoint`=20w?= =?UTF-8?q?ith=20simple=20`isinstance`=20check=20It=20was=20only=20used=20?= =?UTF-8?q?in=20a=20single=20location=20anyway=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netbox/circuits/models.py | 3 --- netbox/dcim/models/device_components.py | 9 --------- netbox/dcim/signals.py | 5 +++-- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index ca792ae2c..57d41a994 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -300,9 +300,6 @@ class CircuitTermination(CableTermination): blank=True ) - # But they are a possible connected endpoint - is_connected_endpoint = True - class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 1d6623117..41ebc1cad 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -86,9 +86,6 @@ class CableTermination(models.Model): object_id_field='termination_b_id' ) - # Whether this class can be a connected endpoint - is_connected_endpoint = True - class Meta: abstract = True @@ -897,9 +894,6 @@ class FrontPort(CableTermination, ComponentModel): csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] - # Whether this class can be a connected endpoint - is_connected_endpoint = False - class Meta: ordering = ('device', '_name') unique_together = ( @@ -967,9 +961,6 @@ class RearPort(CableTermination, ComponentModel): csv_headers = ['device', 'name', 'type', 'positions', 'description'] - # Whether this class can be a connected endpoint - is_connected_endpoint = False - class Meta: ordering = ('device', '_name') unique_together = ('device', 'name') diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index f5bf43c70..2d0f3aa45 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from .choices import CableStatusChoices -from .models import Cable, Device, VirtualChassis +from .models import Cable, Device, FrontPort, RearPort, VirtualChassis @receiver(post_save, sender=VirtualChassis) @@ -63,7 +63,8 @@ def update_connected_endpoints(instance, **kwargs): endpoint_a = path[0][0] endpoint_b = path[-1][2] if not split_ends and not position_stack else None - if getattr(endpoint_a, 'is_connected_endpoint', False) and getattr(endpoint_b, 'is_connected_endpoint', False): + # Patch panel ports are not connected endpoints, everything else is + if not isinstance(endpoint_a, (FrontPort, RearPort)) and not isinstance(endpoint_b, (FrontPort, RearPort)): logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) endpoint_a.connected_endpoint = endpoint_b endpoint_a.connection_status = path_status From d21881e207b318696f854a5d834e118defab3dbd Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 26 Jun 2020 10:57:11 -0500 Subject: [PATCH 24/38] #4695 - Add Metadata class that returns content type choices --- netbox/dcim/api/views.py | 2 ++ netbox/utilities/metadata.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 netbox/utilities/metadata.py diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f70193903..ef9e2fdcc 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -29,6 +29,7 @@ from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, ) from utilities.utils import get_subquery +from utilities.metadata import LimitedMetaData from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -567,6 +568,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): # class CableViewSet(ModelViewSet): + metadata_class = LimitedMetaData queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) diff --git a/netbox/utilities/metadata.py b/netbox/utilities/metadata.py new file mode 100644 index 000000000..2e85126f2 --- /dev/null +++ b/netbox/utilities/metadata.py @@ -0,0 +1,18 @@ +from rest_framework.metadata import SimpleMetadata +from django.utils.encoding import force_str +from utilities.api import ContentTypeField + + +class LimitedMetaData(SimpleMetadata): + + def get_field_info(self, field): + field_info = super().get_field_info(field) + if hasattr(field, 'queryset') and not field_info.get('read_only') and isinstance(field, ContentTypeField): + field_info['choices'] = [ + { + 'value': choice_value, + 'display_name': force_str(choice_name, strings_only=True) + } + for choice_value, choice_name in field.choices.items() + ] + return field_info From 8179cfa4c1aaeb28a6fe0376ae5cf0e459c50ac1 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 26 Jun 2020 11:09:27 -0500 Subject: [PATCH 25/38] #4695 - Rename LimitedMetaData to ContentTypeMetadata --- netbox/dcim/api/views.py | 4 ++-- netbox/utilities/metadata.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ef9e2fdcc..924f14a93 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -29,7 +29,7 @@ from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, ) from utilities.utils import get_subquery -from utilities.metadata import LimitedMetaData +from utilities.metadata import ContentTypeMetadata from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -568,7 +568,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): # class CableViewSet(ModelViewSet): - metadata_class = LimitedMetaData + metadata_class = ContentTypeMetadata queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) diff --git a/netbox/utilities/metadata.py b/netbox/utilities/metadata.py index 2e85126f2..aceef03da 100644 --- a/netbox/utilities/metadata.py +++ b/netbox/utilities/metadata.py @@ -3,7 +3,7 @@ from django.utils.encoding import force_str from utilities.api import ContentTypeField -class LimitedMetaData(SimpleMetadata): +class ContentTypeMetadata(SimpleMetadata): def get_field_info(self, field): field_info = super().get_field_info(field) From 045594759746da5135666819d916f8172962a650 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 26 Jun 2020 18:24:04 +0200 Subject: [PATCH 26/38] Make sure that the endpoint is actually a CableTermination --- netbox/dcim/signals.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 2d0f3aa45..8c8ac67bc 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from .choices import CableStatusChoices -from .models import Cable, Device, FrontPort, RearPort, VirtualChassis +from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis @receiver(post_save, sender=VirtualChassis) @@ -63,8 +63,9 @@ def update_connected_endpoints(instance, **kwargs): endpoint_a = path[0][0] endpoint_b = path[-1][2] if not split_ends and not position_stack else None - # Patch panel ports are not connected endpoints, everything else is - if not isinstance(endpoint_a, (FrontPort, RearPort)) and not isinstance(endpoint_b, (FrontPort, RearPort)): + # Patch panel ports are not connected endpoints, all other cable terminations are + if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \ + isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)): logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) endpoint_a.connected_endpoint = endpoint_b endpoint_a.connection_status = path_status From b26fc811872caf3393be0a6b6c0800f5335ad2df Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 26 Jun 2020 18:42:08 +0200 Subject: [PATCH 27/38] Sort the list for consistent output --- netbox/utilities/metadata.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/utilities/metadata.py b/netbox/utilities/metadata.py index aceef03da..8fd664d5a 100644 --- a/netbox/utilities/metadata.py +++ b/netbox/utilities/metadata.py @@ -15,4 +15,5 @@ class ContentTypeMetadata(SimpleMetadata): } for choice_value, choice_name in field.choices.items() ] + field_info['choices'].sort(key=lambda item: item['display_name']) return field_info From 5dfa80c0b910db0faa71cf7df6ae07752f2b9244 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 15:17:07 -0400 Subject: [PATCH 28/38] Fix the initial permissions check on create/edit/delete view tests --- netbox/utilities/testing/testcases.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 82e88312e..e574afc63 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -220,7 +220,7 @@ class ViewTestCases: # Try GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(self._get_url('add')), 403) + self.assertHttpStatus(self.client.get(self._get_url('add')), 403) # Try GET with permission self.add_permissions( @@ -256,7 +256,7 @@ class ViewTestCases: # Try GET without permission with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403) + self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 403) # Try GET with permission self.add_permissions( @@ -288,7 +288,7 @@ class ViewTestCases: # Try GET without permissions with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403) + self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 403) # Try GET with permission self.add_permissions( From c8461095c9907315adf5ba904fe3df421b77e94c Mon Sep 17 00:00:00 2001 From: Ryan Merolle Date: Fri, 26 Jun 2020 15:34:38 -0400 Subject: [PATCH 29/38] add missing NEMA power ports/outlets (#4784) * add various NEMA power ports/outlets --- netbox/dcim/choices.py | 52 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 479563093..59f30a206 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -260,6 +260,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115P = 'nema-1-15p' TYPE_NEMA_515P = 'nema-5-15p' TYPE_NEMA_520P = 'nema-5-20p' TYPE_NEMA_530P = 'nema-5-30p' @@ -268,16 +269,27 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_620P = 'nema-6-20p' TYPE_NEMA_630P = 'nema-6-30p' TYPE_NEMA_650P = 'nema-6-50p' + TYPE_NEMA_1030P = 'nema-10-30p' + TYPE_NEMA_1050P = 'nema-10-50p' + TYPE_NEMA_1420P = 'nema-14-20p' + TYPE_NEMA_1430P = 'nema-14-30p' + TYPE_NEMA_1450P = 'nema-14-50p' + TYPE_NEMA_1460P = 'nema-14-60p' # NEMA locking + TYPE_NEMA_L115P = 'nema-l1-15p' TYPE_NEMA_L515P = 'nema-l5-15p' TYPE_NEMA_L520P = 'nema-l5-20p' TYPE_NEMA_L530P = 'nema-l5-30p' - TYPE_NEMA_L615P = 'nema-l5-50p' + TYPE_NEMA_L550P = 'nema-l5-50p' + TYPE_NEMA_L615P = 'nema-l6-15p' TYPE_NEMA_L620P = 'nema-l6-20p' TYPE_NEMA_L630P = 'nema-l6-30p' TYPE_NEMA_L650P = 'nema-l6-50p' + TYPE_NEMA_L1030P = 'nema-l10-30p' TYPE_NEMA_L1420P = 'nema-l14-20p' TYPE_NEMA_L1430P = 'nema-l14-30p' + TYPE_NEMA_L1450P = 'nema-l14-50p' + TYPE_NEMA_L1460P = 'nema-l14-60p' TYPE_NEMA_L2120P = 'nema-l21-20p' TYPE_NEMA_L2130P = 'nema-l21-30p' # California style @@ -324,6 +336,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_520P, 'NEMA 5-20P'), (TYPE_NEMA_530P, 'NEMA 5-30P'), @@ -332,17 +345,28 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_620P, 'NEMA 6-20P'), (TYPE_NEMA_630P, 'NEMA 6-30P'), (TYPE_NEMA_650P, 'NEMA 6-50P'), + (TYPE_NEMA_1030P, 'NEMA 10-30P'), + (TYPE_NEMA_1050P, 'NEMA 10-50P'), + (TYPE_NEMA_1420P, 'NEMA 14-20P'), + (TYPE_NEMA_1430P, 'NEMA 14-30P'), + (TYPE_NEMA_1450P, 'NEMA 14-50P'), + (TYPE_NEMA_1460P, 'NEMA 14-60P'), )), ('NEMA (Locking)', ( + (TYPE_NEMA_L115P, 'NEMA L1-15P'), (TYPE_NEMA_L515P, 'NEMA L5-15P'), (TYPE_NEMA_L520P, 'NEMA L5-20P'), (TYPE_NEMA_L530P, 'NEMA L5-30P'), + (TYPE_NEMA_L550P, 'NEMA L5-50P'), (TYPE_NEMA_L615P, 'NEMA L6-15P'), (TYPE_NEMA_L620P, 'NEMA L6-20P'), (TYPE_NEMA_L630P, 'NEMA L6-30P'), (TYPE_NEMA_L650P, 'NEMA L6-50P'), + (TYPE_NEMA_L1030P, 'NEMA L10-30P'), (TYPE_NEMA_L1420P, 'NEMA L14-20P'), (TYPE_NEMA_L1430P, 'NEMA L14-30P'), + (TYPE_NEMA_L1450P, 'NEMA L14-50P'), + (TYPE_NEMA_L1460P, 'NEMA L14-60P'), (TYPE_NEMA_L2120P, 'NEMA L21-20P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'), )), @@ -397,6 +421,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115R = 'nema-1-15r' TYPE_NEMA_515R = 'nema-5-15r' TYPE_NEMA_520R = 'nema-5-20r' TYPE_NEMA_530R = 'nema-5-30r' @@ -405,16 +430,27 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_620R = 'nema-6-20r' TYPE_NEMA_630R = 'nema-6-30r' TYPE_NEMA_650R = 'nema-6-50r' + TYPE_NEMA_1030R = 'nema-10-30r' + TYPE_NEMA_1050R = 'nema-10-50r' + TYPE_NEMA_1420R = 'nema-14-20r' + TYPE_NEMA_1430R = 'nema-14-30r' + TYPE_NEMA_1450R = 'nema-14-50r' + TYPE_NEMA_1460R = 'nema-14-60r' # NEMA locking + TYPE_NEMA_L115R = 'nema-l1-15r' TYPE_NEMA_L515R = 'nema-l5-15r' TYPE_NEMA_L520R = 'nema-l5-20r' TYPE_NEMA_L530R = 'nema-l5-30r' - TYPE_NEMA_L615R = 'nema-l5-50r' + TYPE_NEMA_L550R = 'nema-l5-50r' + TYPE_NEMA_L615R = 'nema-l6-15r' TYPE_NEMA_L620R = 'nema-l6-20r' TYPE_NEMA_L630R = 'nema-l6-30r' TYPE_NEMA_L650R = 'nema-l6-50r' + TYPE_NEMA_L1030R = 'nema-l10-30r' TYPE_NEMA_L1420R = 'nema-l14-20r' TYPE_NEMA_L1430R = 'nema-l14-30r' + TYPE_NEMA_L1450R = 'nema-l14-50r' + TYPE_NEMA_L1460R = 'nema-l14-60r' TYPE_NEMA_L2120R = 'nema-l21-20r' TYPE_NEMA_L2130R = 'nema-l21-30r' # California style @@ -462,6 +498,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_520R, 'NEMA 5-20R'), (TYPE_NEMA_530R, 'NEMA 5-30R'), @@ -470,17 +507,28 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_620R, 'NEMA 6-20R'), (TYPE_NEMA_630R, 'NEMA 6-30R'), (TYPE_NEMA_650R, 'NEMA 6-50R'), + (TYPE_NEMA_1030R, 'NEMA 10-30R'), + (TYPE_NEMA_1050R, 'NEMA 10-50R'), + (TYPE_NEMA_1420R, 'NEMA 14-20R'), + (TYPE_NEMA_1430R, 'NEMA 14-30R'), + (TYPE_NEMA_1450R, 'NEMA 14-50R'), + (TYPE_NEMA_1460R, 'NEMA 14-60R'), )), ('NEMA (Locking)', ( + (TYPE_NEMA_L115R, 'NEMA L1-15R'), (TYPE_NEMA_L515R, 'NEMA L5-15R'), (TYPE_NEMA_L520R, 'NEMA L5-20R'), (TYPE_NEMA_L530R, 'NEMA L5-30R'), + (TYPE_NEMA_L550R, 'NEMA L5-50R'), (TYPE_NEMA_L615R, 'NEMA L6-15R'), (TYPE_NEMA_L620R, 'NEMA L6-20R'), (TYPE_NEMA_L630R, 'NEMA L6-30R'), (TYPE_NEMA_L650R, 'NEMA L6-50R'), + (TYPE_NEMA_L1030R, 'NEMA L10-30R'), (TYPE_NEMA_L1420R, 'NEMA L14-20R'), (TYPE_NEMA_L1430R, 'NEMA L14-30R'), + (TYPE_NEMA_L1450R, 'NEMA L14-50R'), + (TYPE_NEMA_L1460R, 'NEMA L14-60R'), (TYPE_NEMA_L2120R, 'NEMA L21-20R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'), )), From 268b4c854ecf12f76304e8db0261def1598c566f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 09:00:42 -0400 Subject: [PATCH 30/38] Closes #4802: Allow changing page size when displaying only a single page of results --- docs/release-notes/version-2.8.md | 4 ++++ netbox/templates/inc/paginator.html | 28 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index ca264806b..19de521c0 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -2,6 +2,10 @@ ## v2.8.7 (FUTURE) +### Enhancements + +* [#4802](https://github.com/netbox-community/netbox/issues/4802) - Allow changing page size when displaying only a single page of results + ### Bug Fixes * [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html index fe9177f87..c0baef070 100644 --- a/netbox/templates/inc/paginator.html +++ b/netbox/templates/inc/paginator.html @@ -19,21 +19,21 @@ {% endif %} -
- {% for k, v_list in request.GET.lists %} - {% if k != 'per_page' %} - {% for v in v_list %} - - {% endfor %} - {% endif %} - {% endfor %} - per page -
{% endif %} +
+ {% for k, v_list in request.GET.lists %} + {% if k != 'per_page' %} + {% for v in v_list %} + + {% endfor %} + {% endif %} + {% endfor %} + per page +
{% if page %}
Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }} From 51e9b0a22aed39d24811b9ae1b12c04e0ef59c20 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 09:26:32 -0400 Subject: [PATCH 31/38] Closes #4796: Introduce configuration parameters for default rack elevation size --- docs/configuration/optional-settings.md | 16 ++++++++++++++++ docs/release-notes/version-2.8.md | 1 + netbox/dcim/api/serializers.py | 5 +++-- netbox/dcim/constants.py | 2 -- netbox/dcim/models/__init__.py | 4 ++-- netbox/netbox/configuration.example.py | 4 ++++ netbox/netbox/settings.py | 2 ++ netbox/project-static/css/base.css | 7 ------- netbox/templates/dcim/inc/rack_elevation.html | 4 +++- netbox/templates/dcim/rack.html | 12 ++++-------- 10 files changed, 35 insertions(+), 22 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 119e6abf7..e938a4125 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -382,6 +382,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + +Default: 22 + +Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_WIDTH + +Default: 220 + +Default width (in pixels) of a unit within a rack elevation. + +--- + ## REMOTE_AUTH_ENABLED Default: `False` diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 19de521c0..034568539 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -4,6 +4,7 @@ ### Enhancements +* [#4796](https://github.com/netbox-community/netbox/issues/4796) - Introduce configuration parameters for default rack elevation size * [#4802](https://github.com/netbox-community/netbox/issues/4802) - Allow changing page size when displaying only a single page of results ### Bug Fixes diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9ac58dc3a..8b19149b0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -185,10 +186,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=RackElevationDetailRenderChoices.RENDER_JSON ) unit_width = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH ) unit_height = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT ) legend_width = serializers.IntegerField( default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index f938b6f14..66768515c 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -11,8 +11,6 @@ RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 -RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220 -RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22 # diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 98cd37c1c..77c19b8b1 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -731,8 +731,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def get_elevation_svg( self, face=DeviceFaceChoices.FACE_FRONT, - unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, - unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, + unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, + unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, include_images=True, base_url=None diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index ae6a90997..bd3b9806c 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -208,6 +208,10 @@ PLUGINS = [] # prefer IPv4 instead. PREFER_IPV4 = False +# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1. +RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22 +RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220 + # Remote authentication support REMOTE_AUTH_ENABLED = False REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bb1853e03..daca1631f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -99,6 +99,8 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) +RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) +RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 1bba7cd67..9a7ad35ab 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -183,13 +183,6 @@ nav ul.pagination { margin-bottom: 8px !important; } -/* Racks */ -div.rack_header { - margin-left: 32px; - text-align: center; - width: 220px; -} - /* Devices */ table.component-list td.subtable { padding: 0; diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index db5a134c6..a13a3900e 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,4 +1,6 @@ - +
+ +
-
-
-

Front

-
+
+

Front

{% include 'dcim/inc/rack_elevation.html' with face='front' %}
-
-
-

Rear

-
+
+

Rear

{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
From 8a26f475a72e1b648b9c0170c197b20ccd03ce29 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 09:43:05 -0400 Subject: [PATCH 32/38] Fixes #4774: Fix exception when deleting a device with device bays --- docs/release-notes/version-2.8.md | 1 + netbox/dcim/models/device_components.py | 27 +++---------------------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 034568539..ae7005b4e 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -11,6 +11,7 @@ * [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified * [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint +* [#4774](https://github.com/netbox-community/netbox/issues/4774) - Fix exception when deleting a device with device bays * [#4775](https://github.com/netbox-community/netbox/issues/4775) - Allow selecting an alternate device type when creating component templates --- diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4005d41a4..1c02aa727 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -44,6 +44,9 @@ class ComponentModel(models.Model): class Meta: abstract = True + def __str__(self): + return getattr(self, 'name') + def to_objectchange(self, action): # Annotate the parent Device/VM try: @@ -261,9 +264,6 @@ class ConsolePort(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -316,9 +316,6 @@ class ConsoleServerPort(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -397,9 +394,6 @@ class PowerPort(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -547,9 +541,6 @@ class PowerOutlet(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - return self.name - def get_absolute_url(self): return self.device.get_absolute_url() @@ -685,9 +676,6 @@ class Interface(CableTermination, ComponentModel): ordering = ('device', CollateAsChar('_name')) unique_together = ('device', 'name') - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('dcim:interface', kwargs={'pk': self.pk}) @@ -893,9 +881,6 @@ class FrontPort(CableTermination, ComponentModel): ('rear_port', 'rear_port_position'), ) - def __str__(self): - return self.name - def to_csv(self): return ( self.device.identifier, @@ -958,9 +943,6 @@ class RearPort(CableTermination, ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - return self.name - def to_csv(self): return ( self.device.identifier, @@ -1009,9 +991,6 @@ class DeviceBay(ComponentModel): ordering = ('device', '_name') unique_together = ('device', 'name') - def __str__(self): - return '{} - {}'.format(self.device.name, self.name) - def get_absolute_url(self): return self.device.get_absolute_url() From 52cff1ee50f7c9443baaea7ce91431db39054b3e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 09:55:54 -0400 Subject: [PATCH 33/38] Fixes #4771: Fix add/remove tag population when bulk editing objects --- docs/release-notes/version-2.8.md | 1 + netbox/extras/forms.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index ae7005b4e..eae19eac3 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -10,6 +10,7 @@ ### Bug Fixes * [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified +* [#4771](https://github.com/netbox-community/netbox/issues/4771) - Fix add/remove tag population when bulk editing objects * [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint * [#4774](https://github.com/netbox-community/netbox/issues/4774) - Fix exception when deleting a device with device bays * [#4775](https://github.com/netbox-community/netbox/issues/4775) - Allow selecting an alternate device type when creating component templates diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index ac4442df4..bff919005 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -167,8 +167,14 @@ class AddRemoveTagsForm(forms.Form): super().__init__(*args, **kwargs) # Add add/remove tags fields - self.fields['add_tags'] = TagField(required=False) - self.fields['remove_tags'] = TagField(required=False) + self.fields['add_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + self.fields['remove_tags'] = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class TagFilterForm(BootstrapMixin, forms.Form): From 7defa22b0b6062c908d1dfc08d7377d5056fe17d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 15:55:15 -0400 Subject: [PATCH 34/38] Remove reference to choices API endpoints --- docs/development/extending-models.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 19f6ca023..d95edccf9 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -44,11 +44,7 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th Extend the model's API serializer in `.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model. -## 6. Add choices to API view - -If the new field has static choices, add it to the `FieldChoicesViewSet` for the app. - -## 7. Add field to forms +## 6. Add field to forms Extend any forms to include the new field as appropriate. Common forms include: @@ -57,19 +53,19 @@ Extend any forms to include the new field as appropriate. Common forms include: * **CSV import** - The form used when bulk importing objects in CSV format * **Filter** - Displays the options available for filtering a list of objects (both UI and API) -## 8. Extend object filter set +## 7. Extend object filter set If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method. -## 9. Add column to object table +## 8. Add column to object table If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column. -## 10. Update the UI templates +## 9. Update the UI templates Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated. -## 11. Create/extend test cases +## 10. Create/extend test cases Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including: From 0b1df1483fe19ed51f9cfc016bc901af1c4b82b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 16:34:53 -0400 Subject: [PATCH 35/38] Automatically import ContentType when entering nbshell --- netbox/extras/management/commands/nbshell.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 9c9c329e3..48da46525 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -6,6 +6,7 @@ from django import get_version from django.apps import apps from django.conf import settings from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] @@ -52,6 +53,7 @@ class Command(BaseCommand): pass # Additional objects to include + namespace['ContentType'] = ContentType namespace['User'] = User # Load convenience commands From 43d610405f625f2044ebdab0c21df01b75dc856d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 11:06:49 -0400 Subject: [PATCH 36/38] Add changelog for #4695 and #4708 --- docs/release-notes/version-2.8.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index eae19eac3..55a81800d 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -9,6 +9,8 @@ ### Bug Fixes +* [#4695](https://github.com/netbox-community/netbox/issues/4695) - Expose cable termination type choices in OpenAPI spec +* [#4708](https://github.com/netbox-community/netbox/issues/4708) - Relax connection constraints for multi-position rear ports * [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified * [#4771](https://github.com/netbox-community/netbox/issues/4771) - Fix add/remove tag population when bulk editing objects * [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint From 9f614452b48caa5789b752219b7d7e134c09be98 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Jul 2020 09:37:20 -0400 Subject: [PATCH 37/38] Release NetBox v2.8.7 --- docs/release-notes/version-2.8.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 55a81800d..e13e06b62 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,6 +1,6 @@ # NetBox v2.8 -## v2.8.7 (FUTURE) +## v2.8.7 (2020-07-02) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index daca1631f..3216c624b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.7-dev' +VERSION = '2.8.7' # Hostname HOSTNAME = platform.node() From 95462ce0ec30614dd4471509f9f1a2bec7ab261c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Jul 2020 09:39:15 -0400 Subject: [PATCH 38/38] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3216c624b..dfa0ddfff 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.7' +VERSION = '2.8.8-dev' # Hostname HOSTNAME = platform.node()