From a0f4d481dce4ef7bb69f63e271e46cd6504f462c Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Thu, 7 May 2020 02:12:08 +0200 Subject: [PATCH 001/137] 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 002/137] 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 003/137] 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 004/137] 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 005/137] 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 006/137] 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 007/137] 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 008/137] 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 009/137] 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 010/137] 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 011/137] 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 012/137] 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 013/137] 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 014/137] 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 015/137] 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 016/137] 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 017/137] 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 018/137] 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 181bcd70ad7fd40e28a35a792bcf76a795430adb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 12:01:57 -0400 Subject: [PATCH 019/137] Fix schema migrations for device components --- .../migrations/0093_device_component_ordering.py | 16 ++++++++-------- .../0094_device_component_template_ordering.py | 14 +++++++------- .../migrations/0095_primary_model_ordering.py | 6 +++--- .../dcim/migrations/0096_interface_ordering.py | 4 ++-- netbox/utilities/fields.py | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py index 4e3c941a1..925694958 100644 --- a/netbox/dcim/migrations/0093_device_component_ordering.py +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -79,42 +79,42 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='consoleserverport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='devicebay', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='frontport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='inventoryitem', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='poweroutlet', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='powerport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='rearport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_consoleports, diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py index 24fe98e94..70acd3189 100644 --- a/netbox/dcim/migrations/0094_device_component_template_ordering.py +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -75,37 +75,37 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='consoleserverporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='devicebaytemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='frontporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='poweroutlettemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='powerporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='rearporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_consoleporttemplates, diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py index 6225a9b73..2d6be72c8 100644 --- a/netbox/dcim/migrations/0095_primary_model_ordering.py +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -43,17 +43,17 @@ class Migration(migrations.Migration): migrations.AddField( model_name='device', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), ), migrations.AddField( model_name='rack', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='site', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_sites, diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py index f1622f504..7b2663c95 100644 --- a/netbox/dcim/migrations/0096_interface_ordering.py +++ b/netbox/dcim/migrations/0096_interface_ordering.py @@ -35,12 +35,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interface', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), ), migrations.AddField( model_name='interfacetemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), ), migrations.RunPython( code=naturalize_interfacetemplates, diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 4eb19f539..a9b851def 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -68,6 +68,6 @@ class NaturalOrderingField(models.CharField): return ( self.name, 'utilities.fields.NaturalOrderingField', - ['target_field'], + [self.target_field], kwargs, ) From 6cb31a274fdcdf8b93ea5f46cbfa480b4ae70801 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 13:10:56 -0400 Subject: [PATCH 020/137] Initial work on #4721 (WIP) --- .../migrations/0109_interface_remove_vm.py | 18 +++ netbox/dcim/models/__init__.py | 5 +- netbox/dcim/models/device_components.py | 128 +++++++----------- netbox/dcim/tables.py | 17 +-- netbox/dcim/views.py | 2 +- netbox/ipam/constants.py | 7 + netbox/ipam/filters.py | 62 ++++----- netbox/ipam/forms.py | 10 +- .../migrations/0037_ipaddress_assignment.py | 35 +++++ netbox/ipam/models.py | 17 ++- netbox/ipam/tables.py | 14 +- netbox/ipam/tests/test_filters.py | 22 +-- netbox/utilities/filters.py | 4 + netbox/virtualization/api/serializers.py | 8 +- netbox/virtualization/api/views.py | 4 +- netbox/virtualization/filters.py | 4 +- netbox/virtualization/forms.py | 41 +++--- .../migrations/0015_interface.py | 43 ++++++ .../migrations/0016_replicate_interfaces.py | 69 ++++++++++ netbox/virtualization/models.py | 114 +++++++++++++++- netbox/virtualization/tables.py | 3 +- netbox/virtualization/tests/test_api.py | 31 ++--- netbox/virtualization/tests/test_filters.py | 4 +- netbox/virtualization/tests/test_views.py | 16 +-- netbox/virtualization/urls.py | 1 + netbox/virtualization/views.py | 17 ++- 26 files changed, 481 insertions(+), 215 deletions(-) create mode 100644 netbox/dcim/migrations/0109_interface_remove_vm.py create mode 100644 netbox/ipam/migrations/0037_ipaddress_assignment.py create mode 100644 netbox/virtualization/migrations/0015_interface.py create mode 100644 netbox/virtualization/migrations/0016_replicate_interfaces.py diff --git a/netbox/dcim/migrations/0109_interface_remove_vm.py b/netbox/dcim/migrations/0109_interface_remove_vm.py new file mode 100644 index 000000000..97a84a43e --- /dev/null +++ b/netbox/dcim/migrations/0109_interface_remove_vm.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.6 on 2020-06-22 16:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0108_add_tags'), + ('virtualization', '0016_replicate_interfaces'), + ] + + operations = [ + migrations.RemoveField( + model_name='interface', + name='virtual_machine', + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 236979b4a..d8a5f028c 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -35,11 +35,12 @@ from .device_component_templates import ( PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from .device_components import ( - CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet, - PowerPort, RearPort, + BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, + PowerOutlet, PowerPort, RearPort, ) __all__ = ( + 'BaseInterface', 'Cable', 'CableTermination', 'ConsolePort', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a626c055f..8f945622a 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -19,7 +19,6 @@ from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from utilities.utils import serialize_object -from virtualization.choices import VMInterfaceTypeChoices __all__ = ( @@ -53,18 +52,12 @@ class ComponentModel(models.Model): return self.name def to_objectchange(self, action): - # Annotate the parent Device/VM - try: - parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None) - except ObjectDoesNotExist: - # The parent device/VM has already been deleted - parent = None - + # Annotate the parent Device return ObjectChange( changed_object=self, object_repr=str(self), action=action, - related_object=parent, + related_object=self.device, object_data=serialize_object(self) ) @@ -592,26 +585,7 @@ class PowerOutlet(CableTermination, ComponentModel): # Interfaces # -@extras_features('graphs', 'export_templates', 'webhooks') -class Interface(CableTermination, ComponentModel): - """ - A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other - Interface. - """ - device = models.ForeignKey( - to='Device', - on_delete=models.CASCADE, - related_name='interfaces', - null=True, - blank=True - ) - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='interfaces', - null=True, - blank=True - ) +class BaseInterface(models.Model): name = models.CharField( max_length=64 ) @@ -621,6 +595,43 @@ class Interface(CableTermination, ComponentModel): max_length=100, blank=True ) + enabled = models.BooleanField( + default=True + ) + mac_address = MACAddressField( + null=True, + blank=True, + verbose_name='MAC Address' + ) + mtu = models.PositiveIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1), MaxValueValidator(65536)], + verbose_name='MTU' + ) + mode = models.CharField( + max_length=50, + choices=InterfaceModeChoices, + blank=True + ) + + class Meta: + abstract = True + + +@extras_features('graphs', 'export_templates', 'webhooks') +class Interface(CableTermination, ComponentModel, BaseInterface): + """ + A network interface within a Device. A physical Interface can connect to exactly one other + Interface. + """ + device = models.ForeignKey( + to='Device', + on_delete=models.CASCADE, + related_name='interfaces', + null=True, + blank=True + ) label = models.CharField( max_length=64, blank=True, @@ -656,30 +667,11 @@ class Interface(CableTermination, ComponentModel): max_length=50, choices=InterfaceTypeChoices ) - enabled = models.BooleanField( - default=True - ) - mac_address = MACAddressField( - null=True, - blank=True, - verbose_name='MAC Address' - ) - mtu = models.PositiveIntegerField( - blank=True, - null=True, - validators=[MinValueValidator(1), MaxValueValidator(65536)], - verbose_name='MTU' - ) mgmt_only = models.BooleanField( default=False, verbose_name='OOB Management', help_text='This interface is used only for out-of-band management' ) - mode = models.CharField( - max_length=50, - choices=InterfaceModeChoices, - blank=True - ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -694,15 +686,19 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Tagged VLANs' ) + ipaddresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id' + ) tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', + 'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', ] class Meta: - # TODO: ordering and unique_together should include virtual_machine ordering = ('device', CollateAsChar('_name')) unique_together = ('device', 'name') @@ -712,7 +708,6 @@ class Interface(CableTermination, ComponentModel): def to_csv(self): return ( self.device.identifier if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, self.name, self.lag.name if self.lag else None, self.get_type_display(), @@ -726,18 +721,6 @@ class Interface(CableTermination, ComponentModel): def clean(self): - # An Interface must belong to a Device *or* to a VirtualMachine - if self.device and self.virtual_machine: - raise ValidationError("An interface cannot belong to both a device and a virtual machine.") - if not self.device and not self.virtual_machine: - raise ValidationError("An interface must belong to either a device or a virtual machine.") - - # VM interfaces must be virtual - if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values(): - raise ValidationError({ - 'type': "Invalid interface type for a virtual machine: {}".format(self.type) - }) - # Virtual interfaces cannot be connected if self.type in NONCONNECTABLE_IFACE_TYPES and ( self.cable or getattr(self, 'circuit_termination', False) @@ -773,7 +756,7 @@ class Interface(CableTermination, ComponentModel): if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: raise ValidationError({ 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " - "device/VM, or it must be global".format(self.untagged_vlan) + "device, or it must be global".format(self.untagged_vlan) }) def save(self, *args, **kwargs): @@ -788,21 +771,6 @@ class Interface(CableTermination, ComponentModel): return super().save(*args, **kwargs) - def to_objectchange(self, action): - # Annotate the parent Device/VM - try: - parent_obj = self.device or self.virtual_machine - except ObjectDoesNotExist: - parent_obj = None - - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=parent_obj, - object_data=serialize_object(self) - ) - @property def connected_endpoint(self): """ @@ -841,7 +809,7 @@ class Interface(CableTermination, ComponentModel): @property def parent(self): - return self.device or self.virtual_machine + return self.device @property def is_connectable(self): diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1589a7f6d..189f98923 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -863,6 +863,7 @@ class DeviceImportTable(BaseTable): class DeviceComponentDetailTable(BaseTable): pk = ToggleColumn() + device = tables.LinkColumn() name = tables.Column(order_by=('_name',)) cable = tables.LinkColumn() @@ -881,7 +882,6 @@ class ConsolePortTable(BaseTable): class ConsolePortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta): pass @@ -896,7 +896,6 @@ class ConsoleServerPortTable(BaseTable): class ConsoleServerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta): pass @@ -911,7 +910,6 @@ class PowerPortTable(BaseTable): class PowerPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta): pass @@ -926,7 +924,6 @@ class PowerOutletTable(BaseTable): class PowerOutletDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta): pass @@ -940,14 +937,11 @@ class InterfaceTable(BaseTable): class InterfaceDetailTable(DeviceComponentDetailTable): - parent = tables.LinkColumn(order_by=('device', 'virtual_machine')) - name = tables.LinkColumn() enabled = BooleanColumn() - class Meta(InterfaceTable.Meta): - order_by = ('parent', 'name') - fields = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable') - sequence = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable') + class Meta(DeviceComponentDetailTable.Meta, InterfaceTable.Meta): + fields = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable') + sequence = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable') class FrontPortTable(BaseTable): @@ -960,7 +954,6 @@ class FrontPortTable(BaseTable): class FrontPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta): pass @@ -976,7 +969,6 @@ class RearPortTable(BaseTable): class RearPortDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta): pass @@ -991,7 +983,6 @@ class DeviceBayTable(BaseTable): class DeviceBayDetailTable(DeviceComponentDetailTable): - device = tables.LinkColumn() installed_device = tables.LinkColumn() class Meta(DeviceBayTable.Meta): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6aad18bd3..9b19734e6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1442,7 +1442,7 @@ class InterfaceView(ObjectView): # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( - data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=interface.ipaddresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 41075e54a..0a3c67f32 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,3 +1,5 @@ +from django.db.models import Q + from .choices import IPAddressRoleChoices # BGP ASN bounds @@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6 # IPAddresses # +IPADDRESS_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='virtualization', model='interface') +) + IPADDRESS_MASK_LENGTH_MIN = 1 IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 7662d5825..15be58ad4 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -299,37 +299,37 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, to_field_name='rd', label='VRF (RD)', ) - device = MultiValueCharFilter( - method='filter_device', - field_name='name', - label='Device (name)', - ) - device_id = MultiValueNumberFilter( - method='filter_device', - field_name='pk', - label='Device (ID)', - ) - virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - field_name='interface__virtual_machine', - queryset=VirtualMachine.objects.unrestricted(), - label='Virtual machine (ID)', - ) - virtual_machine = django_filters.ModelMultipleChoiceFilter( - field_name='interface__virtual_machine__name', - queryset=VirtualMachine.objects.unrestricted(), - to_field_name='name', - label='Virtual machine (name)', - ) - interface = django_filters.ModelMultipleChoiceFilter( - field_name='interface__name', - queryset=Interface.objects.unrestricted(), - to_field_name='name', - label='Interface (ID)', - ) - interface_id = django_filters.ModelMultipleChoiceFilter( - queryset=Interface.objects.unrestricted(), - label='Interface (ID)', - ) + # device = MultiValueCharFilter( + # method='filter_device', + # field_name='name', + # label='Device (name)', + # ) + # device_id = MultiValueNumberFilter( + # method='filter_device', + # field_name='pk', + # label='Device (ID)', + # ) + # virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + # field_name='interface__virtual_machine', + # queryset=VirtualMachine.objects.unrestricted(), + # label='Virtual machine (ID)', + # ) + # virtual_machine = django_filters.ModelMultipleChoiceFilter( + # field_name='interface__virtual_machine__name', + # queryset=VirtualMachine.objects.unrestricted(), + # to_field_name='name', + # label='Virtual machine (name)', + # ) + # interface = django_filters.ModelMultipleChoiceFilter( + # field_name='interface__name', + # queryset=Interface.objects.unrestricted(), + # to_field_name='name', + # label='Interface (ID)', + # ) + # interface_id = django_filters.ModelMultipleChoiceFilter( + # queryset=Interface.objects.unrestricted(), + # label='Interface (ID)', + # ) assigned_to_interface = django_filters.BooleanFilter( method='_assigned_to_interface', label='Is assigned to an interface', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b332bf33f..620638703 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator from dcim.models import Device, Interface, Rack, Region, Site @@ -14,7 +15,7 @@ from utilities.forms import ( ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import VirtualMachine +from virtualization.models import Interface as VMInterface, VirtualMachine from .choices import * from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -1194,13 +1195,14 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): # Limit IP address choices to those assigned to interfaces of the parent device/VM if self.instance.device: - vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')] self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface_id__in=vc_interface_ids + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=self.instance.device.vc_interfaces.values('id', flat=True) ) elif self.instance.virtual_machine: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - interface__virtual_machine=self.instance.virtual_machine + assigned_object_type=ContentType.objects.get_for_model(VMInterface), + assigned_object_id__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True) ) else: self.fields['ipaddresses'].choices = [] diff --git a/netbox/ipam/migrations/0037_ipaddress_assignment.py b/netbox/ipam/migrations/0037_ipaddress_assignment.py new file mode 100644 index 000000000..4586a5088 --- /dev/null +++ b/netbox/ipam/migrations/0037_ipaddress_assignment.py @@ -0,0 +1,35 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def set_assigned_object_type(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + IPAddress = apps.get_model('ipam', 'IPAddress') + + device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk + IPAddress.objects.update(assigned_object_type=device_ct) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0036_standardize_description'), + ] + + operations = [ + migrations.RenameField( + model_name='ipaddress', + old_name='interface', + new_name='assigned_object_id', + ), + migrations.AddField( + model_name='ipaddress', + name='assigned_object_type', + field=models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'interface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType', blank=True, null=True), + preserve_default=False, + ), + migrations.RunPython( + code=set_assigned_object_type + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index b99a6c919..ba7c959dd 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,6 +1,7 @@ import netaddr from django.conf import settings -from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -606,13 +607,25 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): blank=True, help_text='The functional role of this IP' ) - interface = models.ForeignKey( + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + assigned_object_id = models.ForeignKey( to='dcim.Interface', on_delete=models.CASCADE, related_name='ip_addresses', blank=True, null=True ) + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) nat_inside = models.OneToOneField( to='self', on_delete=models.SET_NULL, diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ca48c2951..989fe0844 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -431,18 +431,14 @@ class IPAddressTable(BaseTable): tenant = tables.TemplateColumn( template_code=TENANT_LINK ) - parent = tables.TemplateColumn( - template_code=IPADDRESS_PARENT, - orderable=False - ) - interface = tables.Column( - orderable=False + assigned = tables.BooleanColumn( + accessor='assigned_object_id' ) class Meta(BaseTable.Meta): model = IPAddress fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', ) row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', @@ -465,11 +461,11 @@ class IPAddressDetailTable(IPAddressTable): class Meta(IPAddressTable.Meta): fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', 'tags', ) default_columns = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', ) diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 785f5f2c5..24d0d7fa8 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, from ipam.choices import * from ipam.filters import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from virtualization.models import Cluster, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterType, Interfaces as VMInterface, VirtualMachine from tenancy.models import Tenant, TenantGroup @@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase): ) Device.objects.bulk_create(devices) + interfaces = ( + Interface(device=devices[0], name='Interface 1'), + Interface(device=devices[1], name='Interface 2'), + Interface(device=devices[2], name='Interface 3'), + ) + Interface.objects.bulk_create(interfaces) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(type=clustertype, name='Cluster 1') @@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase): ) VirtualMachine.objects.bulk_create(virtual_machines) - interfaces = ( - Interface(device=devices[0], name='Interface 1'), - Interface(device=devices[1], name='Interface 2'), - Interface(device=devices[2], name='Interface 3'), - Interface(virtual_machine=virtual_machines[0], name='Interface 1'), - Interface(virtual_machine=virtual_machines[1], name='Interface 2'), - Interface(virtual_machine=virtual_machines[2], name='Interface 3'), + vm_interfaces = ( + VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'), + VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'), + VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'), ) - Interface.objects.bulk_create(interfaces) + VMInterface.objects.bulk_create(vm_interfaces) tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..2b49dd99e 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -256,6 +256,10 @@ class BaseFilterSet(django_filters.FilterSet): except django_filters.exceptions.FieldLookupError: # The filter could not be created because the lookup expression is not supported on the field continue + except Exception as e: + print(existing_filter_name, existing_filter) + print(f'field: {field}, lookup_expr: {lookup_expr}') + raise e if lookup_name.startswith('n'): # This is a negation filter which requires a queryset.exclude() clause diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 008c6dd88..a437a000c 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,7 +3,6 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -11,7 +10,7 @@ from ipam.models import VLAN from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine from .nested_serializers import * @@ -97,7 +96,6 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -110,6 +108,6 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class Meta: model = Interface fields = [ - 'id', 'virtual_machine', 'name', 'type', 'enabled', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'id', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', + 'tagged_vlans', 'tags', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 2a1d7c3a9..bcff543a8 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,11 +1,11 @@ from django.db.models import Count -from dcim.models import Device, Interface +from dcim.models import Device from extras.api.views import CustomFieldModelViewSet from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine from . import serializers diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 7e8349cf1..dd1c3e4b2 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Interface, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( @@ -9,7 +9,7 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine __all__ = ( 'ClusterFilterSet', diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 942368f19..5789dff88 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,10 +1,11 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import INTERFACE_MODE_HELP_TEXT -from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -16,10 +17,10 @@ from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, - StaticSelect2, StaticSelect2Multiple, TagFilterField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine # @@ -355,8 +356,11 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): for family in [4, 6]: ip_choices = [(None, '---------')] # Collect interface IPs + interface_pks = self.instance.interfaces.values_list('id', flat=True) interface_ips = IPAddress.objects.prefetch_related('interface').filter( - address__family=family, interface__virtual_machine=self.instance + address__family=family, + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=interface_pks ) if interface_ips: ip_choices.append( @@ -600,12 +604,11 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'virtual_machine', 'name', 'type', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', + 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', + 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), - 'type': forms.HiddenInput(), 'mode': StaticSelect2() } labels = { @@ -619,7 +622,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): super().__init__(*args, **kwargs) # Add current site to VLANs query params - site = getattr(self.instance.parent, 'site', None) + site = getattr(self.instance.virtual_machine, 'site', None) if site is not None: # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) @@ -650,11 +653,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - type = forms.ChoiceField( - choices=VMInterfaceTypeChoices, - initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, - widget=forms.HiddenInput() - ) enabled = forms.BooleanField( required=False, initial=True @@ -789,6 +787,17 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) +class InterfaceFilterForm(forms.Form): + model = Interface + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + # # Bulk VirtualMachine component creation # @@ -812,8 +821,4 @@ class InterfaceBulkCreateForm( form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), VirtualMachineBulkAddComponentForm ): - type = forms.ChoiceField( - choices=VMInterfaceTypeChoices, - initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, - widget=forms.HiddenInput() - ) + pass diff --git a/netbox/virtualization/migrations/0015_interface.py b/netbox/virtualization/migrations/0015_interface.py new file mode 100644 index 000000000..7ad22eeb8 --- /dev/null +++ b/netbox/virtualization/migrations/0015_interface.py @@ -0,0 +1,43 @@ +# Generated by Django 3.0.6 on 2020-06-18 20:21 + +import dcim.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.ordering +import utilities.query_functions + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0036_standardize_description'), + ('extras', '0042_customfield_manager'), + ('virtualization', '0014_standardize_description'), + ] + + operations = [ + migrations.CreateModel( + name='Interface', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)), + ('enabled', models.BooleanField(default=True)), + ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)), + ('mtu', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)])), + ('mode', models.CharField(blank=True, max_length=50)), + ('description', models.CharField(blank=True, max_length=200)), + ('tagged_vlans', models.ManyToManyField(blank=True, related_name='vm_interfaces_as_tagged', to='ipam.VLAN')), + ('tags', taggit.managers.TaggableManager(related_name='vm_interface', through='extras.TaggedItem', to='extras.Tag')), + ('untagged_vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vm_interfaces_as_untagged', to='ipam.VLAN')), + ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine')), + ], + options={ + 'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')), + 'unique_together': {('virtual_machine', 'name')}, + }, + ), + ] diff --git a/netbox/virtualization/migrations/0016_replicate_interfaces.py b/netbox/virtualization/migrations/0016_replicate_interfaces.py new file mode 100644 index 000000000..c259b4140 --- /dev/null +++ b/netbox/virtualization/migrations/0016_replicate_interfaces.py @@ -0,0 +1,69 @@ +import sys + +from django.db import migrations + + +def replicate_interfaces(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + TaggedItem = apps.get_model('taggit', 'TaggedItem') + Interface = apps.get_model('dcim', 'Interface') + IPAddress = apps.get_model('ipam', 'IPAddress') + VMInterface = apps.get_model('virtualization', 'Interface') + + interface_ct = ContentType.objects.get_for_model(Interface) + vm_interface_ct = ContentType.objects.get_for_model(VMInterface) + + # Replicate dcim.Interface instances assigned to VirtualMachines + original_interfaces = Interface.objects.filter(virtual_machine__isnull=False) + for interface in original_interfaces: + vm_interface = VMInterface( + virtual_machine=interface.virtual_machine, + name=interface.name, + enabled=interface.enabled, + mac_address=interface.mac_address, + mtu=interface.mtu, + mode=interface.mode, + description=interface.description, + untagged_vlan=interface.untagged_vlan, + ) + vm_interface.save() + + # Copy tagged VLANs + vm_interface.tagged_vlans.set(interface.tagged_vlans.all()) + + # Reassign tags to the new instance + TaggedItem.objects.filter( + content_type=interface_ct, object_id=interface.pk + ).update( + content_type=vm_interface_ct, object_id=vm_interface.pk + ) + + # Update any assigned IPAddresses + IPAddress.objects.filter(assigned_object_id=interface.pk).update( + assigned_object_type=vm_interface_ct, + assigned_object_id=vm_interface.pk + ) + + replicated_count = VMInterface.objects.count() + if 'test' not in sys.argv: + print(f"\n Replicated {replicated_count} interfaces ", end='', flush=True) + + # Verify that all interfaces have been replicated + assert replicated_count == original_interfaces.count(), "Replicated interfaces count does not match original count!" + + # Delete original VM interfaces + original_interfaces.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0037_ipaddress_assignment'), + ('virtualization', '0015_interface'), + ] + + operations = [ + migrations.RunPython( + code=replicate_interfaces + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8ad40bab7..8d4d5d889 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -5,11 +5,14 @@ from django.db import models from django.urls import reverse from taggit.managers import TaggableManager -from dcim.models import Device -from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from dcim.choices import InterfaceModeChoices +from dcim.models import BaseInterface, Device +from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features from utilities.models import ChangeLoggedModel +from utilities.query_functions import CollateAsChar from utilities.querysets import RestrictedQuerySet +from utilities.utils import serialize_object from .choices import * @@ -17,6 +20,7 @@ __all__ = ( 'Cluster', 'ClusterGroup', 'ClusterType', + 'Interface', 'VirtualMachine', ) @@ -370,3 +374,109 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): @property def site(self): return self.cluster.site + + +# +# Interfaces +# + +@extras_features('graphs', 'export_templates', 'webhooks') +class Interface(BaseInterface): + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces' + ) + description = models.CharField( + max_length=200, + blank=True + ) + untagged_vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.SET_NULL, + related_name='vm_interfaces_as_untagged', + null=True, + blank=True, + verbose_name='Untagged VLAN' + ) + tagged_vlans = models.ManyToManyField( + to='ipam.VLAN', + related_name='vm_interfaces_as_tagged', + blank=True, + verbose_name='Tagged VLANs' + ) + ipaddresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id' + ) + tags = TaggableManager( + through=TaggedItem, + related_name='vm_interface' + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = [ + 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + ] + + class Meta: + ordering = ('virtual_machine', CollateAsChar('_name')) + unique_together = ('virtual_machine', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('virtualization:interface', kwargs={'pk': self.pk}) + + def to_csv(self): + return ( + self.virtual_machine.name, + self.name, + self.enabled, + self.mac_address, + self.mtu, + self.description, + self.get_mode_display(), + ) + + def clean(self): + + # Validate untagged VLAN + if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: + raise ValidationError({ + 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " + "virtual machine, or it must be global".format(self.untagged_vlan) + }) + + def save(self, *args, **kwargs): + + # Remove untagged VLAN assignment for non-802.1Q interfaces + if self.mode is None: + self.untagged_vlan = None + + # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) + if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: + self.tagged_vlans.clear() + + return super().save(*args, **kwargs) + + def to_objectchange(self, action): + # Annotate the parent VirtualMachine + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + related_object=self.virtual_machine, + object_data=serialize_object(self) + ) + + @property + def parent(self): + return self.virtual_machine + + @property + def count_ipaddresses(self): + return self.ip_addresses.count() diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index d957e0053..97831a458 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,10 +1,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine CLUSTERTYPE_ACTIONS = """ diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 6b466116e..3027211f2 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,11 +2,9 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices -from dcim.models import Interface from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases -from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine class AppTest(APITestCase): @@ -207,18 +205,15 @@ class InterfaceTest(APITestCase): self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') self.interface1 = Interface.objects.create( virtual_machine=self.virtualmachine, - name='Test Interface 1', - type=InterfaceTypeChoices.TYPE_VIRTUAL + name='Test Interface 1' ) self.interface2 = Interface.objects.create( virtual_machine=self.virtualmachine, - name='Test Interface 2', - type=InterfaceTypeChoices.TYPE_VIRTUAL + name='Test Interface 2' ) self.interface3 = Interface.objects.create( virtual_machine=self.virtualmachine, - name='Test Interface 3', - type=InterfaceTypeChoices.TYPE_VIRTUAL + name='Test Interface 3' ) self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) @@ -227,21 +222,21 @@ class InterfaceTest(APITestCase): def test_get_interface(self): url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.view_interface') + self.add_permissions('virtualization.view_interface') response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.interface1.name) def test_list_interfaces(self): url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.view_interface') + self.add_permissions('virtualization.view_interface') response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) def test_list_interfaces_brief(self): url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.view_interface') + self.add_permissions('virtualization.view_interface') response = self.client.get('{}?brief=1'.format(url), **self.header) self.assertEqual( @@ -255,7 +250,7 @@ class InterfaceTest(APITestCase): 'name': 'Test Interface 4', } url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -273,7 +268,7 @@ class InterfaceTest(APITestCase): 'tagged_vlans': [self.vlan1.id, self.vlan2.id], } url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -299,7 +294,7 @@ class InterfaceTest(APITestCase): }, ] url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -333,7 +328,7 @@ class InterfaceTest(APITestCase): }, ] url = reverse('virtualization-api:interface-list') - self.add_permissions('dcim.add_interface') + self.add_permissions('virtualization.add_interface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -349,7 +344,7 @@ class InterfaceTest(APITestCase): 'name': 'Test Interface X', } url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.change_interface') + self.add_permissions('virtualization.change_interface') response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -359,7 +354,7 @@ class InterfaceTest(APITestCase): def test_delete_interface(self): url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('dcim.delete_interface') + self.add_permissions('virtualization.delete_interface') response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index 51c7c6e8d..562ed9901 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -1,10 +1,10 @@ from django.test import TestCase -from dcim.models import DeviceRole, Interface, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from virtualization.choices import * from virtualization.filters import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine class ClusterTypeTestCase(TestCase): diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index a98496f29..b8e1f92c5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,11 +1,11 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Interface, Platform, Site +from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN from utilities.testing import ViewTestCases from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -201,10 +201,6 @@ class InterfaceTestCase( ): model = Interface - def _get_base_url(self): - # Interface belongs to the DCIM app, so we have to override the base URL - return 'virtualization:interface_{}' - @classmethod def setUpTestData(cls): @@ -219,9 +215,9 @@ class InterfaceTestCase( VirtualMachine.objects.bulk_create(virtualmachines) Interface.objects.bulk_create([ - Interface(virtual_machine=virtualmachines[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), - Interface(virtual_machine=virtualmachines[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), - Interface(virtual_machine=virtualmachines[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(virtual_machine=virtualmachines[0], name='Interface 1'), + Interface(virtual_machine=virtualmachines[0], name='Interface 2'), + Interface(virtual_machine=virtualmachines[0], name='Interface 3'), ]) vlans = ( @@ -237,7 +233,6 @@ class InterfaceTestCase( cls.form_data = { 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', - 'type': InterfaceTypeChoices.TYPE_VIRTUAL, 'enabled': False, 'mgmt_only': False, 'mac_address': EUI('01-02-03-04-05-06'), @@ -252,7 +247,6 @@ class InterfaceTestCase( cls.bulk_create_data = { 'virtual_machine': virtualmachines[1].pk, 'name_pattern': 'Interface [4-6]', - 'type': InterfaceTypeChoices.TYPE_VIRTUAL, 'enabled': False, 'mgmt_only': False, 'mac_address': EUI('01-02-03-04-05-06'), diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 38ad1a8b1..4e29f861a 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -54,6 +54,7 @@ urlpatterns = [ path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + path('interfaces//', views.InterfaceView.as_view(), name='interface'), path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index aea4d0556..a64b9b9db 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -4,7 +4,8 @@ from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from dcim.models import Device, Interface +from dcim.models import Device +from dcim.views import InterfaceView as DeviceInterfaceView from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import Service @@ -13,7 +14,7 @@ from utilities.views import ( ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine # @@ -288,6 +289,18 @@ class VirtualMachineBulkDeleteView(BulkDeleteView): # VM interfaces # +class InterfaceListView(ObjectListView): + queryset = Interface.objects.prefetch_related('virtual_machine', 'virtual_machine__tenant', 'cable') + filterset = filters.InterfaceFilterSet + filterset_form = forms.InterfaceFilterForm + table = tables.InterfaceTable + action_buttons = ('import', 'export') + + +class InterfaceView(DeviceInterfaceView): + queryset = Interface.objects.all() + + class InterfaceCreateView(ComponentCreateView): queryset = Interface.objects.all() form = forms.InterfaceCreateForm From e76b1f1daae8919d4e4ad1872100c8fd478acc86 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 13:50:14 -0400 Subject: [PATCH 021/137] Fix assigned_object field --- netbox/ipam/migrations/0037_ipaddress_assignment.py | 5 +++++ netbox/ipam/models.py | 7 ++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/migrations/0037_ipaddress_assignment.py b/netbox/ipam/migrations/0037_ipaddress_assignment.py index 4586a5088..607f832a5 100644 --- a/netbox/ipam/migrations/0037_ipaddress_assignment.py +++ b/netbox/ipam/migrations/0037_ipaddress_assignment.py @@ -23,6 +23,11 @@ class Migration(migrations.Migration): old_name='interface', new_name='assigned_object_id', ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), migrations.AddField( model_name='ipaddress', name='assigned_object_type', diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index ba7c959dd..640d29834 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -615,10 +615,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) - assigned_object_id = models.ForeignKey( - to='dcim.Interface', - on_delete=models.CASCADE, - related_name='ip_addresses', + assigned_object_id = models.PositiveIntegerField( blank=True, null=True ) @@ -660,7 +657,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): 'dns_name', 'description', ] clone_fields = [ - 'vrf', 'tenant', 'status', 'role', 'description', 'interface', + 'vrf', 'tenant', 'status', 'role', 'description', ] STATUS_CLASS_MAP = { From 2608b3f9f391ca85a9cd9a78b3624d529a7e4e30 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 14:33:53 -0400 Subject: [PATCH 022/137] Separate VM interface view and template --- .../templates/virtualization/interface.html | 120 ++++++++++++++++++ netbox/virtualization/views.py | 34 ++++- 2 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 netbox/templates/virtualization/interface.html diff --git a/netbox/templates/virtualization/interface.html b/netbox/templates/virtualization/interface.html new file mode 100644 index 000000000..15b432a3f --- /dev/null +++ b/netbox/templates/virtualization/interface.html @@ -0,0 +1,120 @@ +{% extends 'base.html' %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+ {% if perms.dcim.change_interface %} + + Edit + + {% endif %} + {% if perms.dcim.delete_interface %} + + Delete + + {% endif %} +
+

{% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}

+ +{% endblock %} + +{% block content %} +
+
+
+
+ Interface +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% if interface.device %}Device{% else %}Virtual Machine{% endif %} + {{ interface.parent }} +
Name{{ interface.name }}
Label{{ interface.label|placeholder }}
Type{{ interface.get_type_display }}
Enabled + {% if interface.enabled %} + + {% else %} + + {% endif %} +
LAG + {% if interface.lag%} + {{ interface.lag }} + {% else %} + None + {% endif %} +
Description{{ interface.description|placeholder }}
MTU{{ interface.mtu|placeholder }}
MAC Address{{ interface.mac_address|placeholder }}
802.1Q Mode{{ interface.get_mode_display }}
+
+ {% include 'extras/inc/tags_panel.html' with tags=interface.tags.all %} +
+
+
+
+ {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
+
+
+
+ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
+
+{% endblock %} diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a64b9b9db..bd700d16b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -5,10 +5,10 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from dcim.models import Device -from dcim.views import InterfaceView as DeviceInterfaceView from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import Service +from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -297,9 +297,39 @@ class InterfaceListView(ObjectListView): action_buttons = ('import', 'export') -class InterfaceView(DeviceInterfaceView): +class InterfaceView(ObjectView): queryset = Interface.objects.all() + def get(self, request, pk): + + interface = get_object_or_404(self.queryset, pk=pk) + + # Get assigned IP addresses + ipaddress_table = InterfaceIPAddressTable( + data=interface.ipaddresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + orderable=False + ) + + # Get assigned VLANs and annotate whether each is tagged or untagged + vlans = [] + if interface.untagged_vlan is not None: + vlans.append(interface.untagged_vlan) + vlans[0].tagged = False + for vlan in interface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'): + vlan.tagged = True + vlans.append(vlan) + vlan_table = InterfaceVLANTable( + interface=interface, + data=vlans, + orderable=False + ) + + return render(request, 'virtualization/interface.html', { + 'interface': interface, + 'ipaddress_table': ipaddress_table, + 'vlan_table': vlan_table, + }) + class InterfaceCreateView(ComponentCreateView): queryset = Interface.objects.all() From 31bb70d9a251cfb39cbb77f03907bfd1be12a554 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 14:46:25 -0400 Subject: [PATCH 023/137] Fixed IPAM tests --- netbox/ipam/api/views.py | 3 +- netbox/ipam/filters.py | 74 ++++++----- netbox/ipam/forms.py | 142 +++++++++++----------- netbox/ipam/models.py | 29 +---- netbox/ipam/tables.py | 4 +- netbox/ipam/tests/test_filters.py | 35 +++--- netbox/ipam/tests/test_views.py | 1 - netbox/ipam/views.py | 90 +++++++------- netbox/templates/ipam/ipaddress.html | 8 +- netbox/virtualization/tests/test_views.py | 7 -- 10 files changed, 188 insertions(+), 205 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 60bfade24..0f84ee772 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -233,8 +233,7 @@ class PrefixViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine', - 'nat_outside', 'tags', + 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', ) serializer_class = serializers.IPAddressSerializer filterset_class = filters.IPAddressFilterSet diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 15be58ad4..aa3fa885b 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,5 +1,6 @@ import django_filters import netaddr +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from netaddr.core import AddrFormatError @@ -11,7 +12,7 @@ from utilities.filters import ( BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, ) -from virtualization.models import VirtualMachine +from virtualization.models import Interface as VMInterface, VirtualMachine from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -299,27 +300,26 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, to_field_name='rd', label='VRF (RD)', ) - # device = MultiValueCharFilter( - # method='filter_device', - # field_name='name', - # label='Device (name)', - # ) - # device_id = MultiValueNumberFilter( - # method='filter_device', - # field_name='pk', - # label='Device (ID)', - # ) - # virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - # field_name='interface__virtual_machine', - # queryset=VirtualMachine.objects.unrestricted(), - # label='Virtual machine (ID)', - # ) - # virtual_machine = django_filters.ModelMultipleChoiceFilter( - # field_name='interface__virtual_machine__name', - # queryset=VirtualMachine.objects.unrestricted(), - # to_field_name='name', - # label='Virtual machine (name)', - # ) + device = MultiValueCharFilter( + method='filter_device', + field_name='name', + label='Device (name)', + ) + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label='Device (ID)', + ) + virtual_machine = MultiValueCharFilter( + method='filter_virtual_machine', + field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = MultiValueNumberFilter( + method='filter_virtual_machine', + field_name='pk', + label='Virtual machine (ID)', + ) # interface = django_filters.ModelMultipleChoiceFilter( # field_name='interface__name', # queryset=Interface.objects.unrestricted(), @@ -379,17 +379,31 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, return queryset.filter(address__net_mask_length=value) def filter_device(self, queryset, name, value): - try: - devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) - vc_interface_ids = [] - for device in devices: - vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')]) - return queryset.filter(interface_id__in=vc_interface_ids) - except Device.DoesNotExist: + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces.values_list('id', flat=True)) + return queryset.filter( + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=interface_ids + ) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value}) + if not virtual_machines.exists(): + return queryset.none() + interface_ids = [] + for vm in virtual_machines: + interface_ids.extend(vm.interfaces.values_list('id', flat=True)) + return queryset.filter( + assigned_object_type=ContentType.objects.get_for_model(VMInterface), + assigned_object_id__in=interface_ids + ) def _assigned_to_interface(self, queryset, name, value): - return queryset.exclude(interface__isnull=value) + return queryset.exclude(assigned_object_id__isnull=value) class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 620638703..a66a306da 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -523,10 +523,10 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) # class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm): - interface = forms.ModelChoiceField( - queryset=Interface.objects.all(), - required=False - ) + # interface = forms.ModelChoiceField( + # queryset=Interface.objects.all(), + # required=False + # ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -598,8 +598,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent', - 'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack', + 'nat_inside', 'tenant_group', 'tenant', 'tags', ] widgets = { 'status': StaticSelect2(), @@ -621,27 +621,27 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel self.fields['vrf'].empty_label = 'Global' - # Limit interface selections to those belonging to the parent device/VM - if self.instance and self.instance.interface: - self.fields['interface'].queryset = Interface.objects.filter( - device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine - ).prefetch_related( - 'device__primary_ip4', - 'device__primary_ip6', - 'virtual_machine__primary_ip4', - 'virtual_machine__primary_ip6', - ) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save() - else: - self.fields['interface'].choices = [] - - # Initialize primary_for_parent if IP address is already assigned - if self.instance.pk and self.instance.interface is not None: - parent = self.instance.interface.parent - if ( - self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or - self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk - ): - self.initial['primary_for_parent'] = True + # # Limit interface selections to those belonging to the parent device/VM + # if self.instance and self.instance.interface: + # self.fields['interface'].queryset = Interface.objects.filter( + # device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine + # ).prefetch_related( + # 'device__primary_ip4', + # 'device__primary_ip6', + # 'virtual_machine__primary_ip4', + # 'virtual_machine__primary_ip6', + # ) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save() + # else: + # self.fields['interface'].choices = [] + # + # # Initialize primary_for_parent if IP address is already assigned + # if self.instance.pk and self.instance.interface is not None: + # parent = self.instance.interface.parent + # if ( + # self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or + # self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk + # ): + # self.initial['primary_for_parent'] = True def clean(self): super().clean() @@ -664,14 +664,14 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel else: parent.primary_ip6 = ipaddress parent.save() - elif self.cleaned_data['interface']: - parent = self.cleaned_data['interface'].parent - if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: - parent.primary_ip4 = None - parent.save() - elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: - parent.primary_ip6 = None - parent.save() + # elif self.cleaned_data['interface']: + # parent = self.cleaned_data['interface'].parent + # if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: + # parent.primary_ip4 = None + # parent.save() + # elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: + # parent.primary_ip6 = None + # parent.save() return ipaddress @@ -730,24 +730,24 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): required=False, help_text='Functional role' ) - device = CSVModelChoiceField( - queryset=Device.objects.all(), - required=False, - to_field_name='name', - help_text='Parent device of assigned interface (if any)' - ) - virtual_machine = CSVModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - to_field_name='name', - help_text='Parent VM of assigned interface (if any)' - ) - interface = CSVModelChoiceField( - queryset=Interface.objects.all(), - required=False, - to_field_name='name', - help_text='Assigned interface' - ) + # device = CSVModelChoiceField( + # queryset=Device.objects.all(), + # required=False, + # to_field_name='name', + # help_text='Parent device of assigned interface (if any)' + # ) + # virtual_machine = CSVModelChoiceField( + # queryset=VirtualMachine.objects.all(), + # required=False, + # to_field_name='name', + # help_text='Parent VM of assigned interface (if any)' + # ) + # interface = CSVModelChoiceField( + # queryset=Interface.objects.all(), + # required=False, + # to_field_name='name', + # help_text='Assigned interface' + # ) is_primary = forms.BooleanField( help_text='Make this the primary IP for the assigned device', required=False @@ -760,23 +760,23 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) - if data: - - # Limit interface queryset by assigned device or virtual machine - if data.get('device'): - params = { - f"device__{self.fields['device'].to_field_name}": data.get('device') - } - elif data.get('virtual_machine'): - params = { - f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine') - } - else: - params = { - 'device': None, - 'virtual_machine': None, - } - self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params) + # if data: + # + # # Limit interface queryset by assigned device or virtual machine + # if data.get('device'): + # params = { + # f"device__{self.fields['device'].to_field_name}": data.get('device') + # } + # elif data.get('virtual_machine'): + # params = { + # f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine') + # } + # else: + # params = { + # 'device': None, + # 'virtual_machine': None, + # } + # self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params) def clean(self): super().clean() @@ -1197,7 +1197,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): if self.instance.device: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( assigned_object_type=ContentType.objects.get_for_model(Interface), - assigned_object_id__in=self.instance.device.vc_interfaces.values('id', flat=True) + assigned_object_id__in=self.instance.device.vc_interfaces.values_list('id', flat=True) ) elif self.instance.virtual_machine: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 640d29834..11eddb07a 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, Q +from django.db.models import F from django.urls import reverse from taggit.managers import TaggableManager @@ -653,7 +653,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): objects = IPAddressManager() csv_headers = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', + 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary', 'dns_name', 'description', ] clone_fields = [ @@ -753,17 +753,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): super().save(*args, **kwargs) def to_objectchange(self, action): - # Annotate the assigned Interface (if any) - try: - parent_obj = self.interface - except ObjectDoesNotExist: - parent_obj = None - return ObjectChange( changed_object=self, object_repr=str(self), action=action, - related_object=parent_obj, + related_object=self.assigned_object, object_data=serialize_object(self) ) @@ -783,9 +777,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.tenant.name if self.tenant else None, self.get_status_display(), self.get_role_display(), - self.device.identifier if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, - self.interface.name if self.interface else None, + '{}.{}'.format(self.assigned_object_type.app_label, self.assigned_object_type.model) if self.assigned_object_type else None, + self.assigned_object_id, is_primary, self.dns_name, self.description, @@ -806,18 +799,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.address.prefixlen = value mask_length = property(fset=_set_mask_length) - @property - def device(self): - if self.interface: - return self.interface.device - return None - - @property - def virtual_machine(self): - if self.interface: - return self.interface.virtual_machine - return None - def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 989fe0844..8f731b7ae 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -481,13 +481,13 @@ class IPAddressAssignTable(BaseTable): template_code=IPADDRESS_PARENT, orderable=False ) - interface = tables.Column( + assigned_object = tables.Column( orderable=False ) class Meta(BaseTable.Meta): model = IPAddress - fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') + fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'assigned_object', 'description') orderable = False diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 24d0d7fa8..8382ae409 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, from ipam.choices import * from ipam.filters import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from virtualization.models import Cluster, ClusterType, Interfaces as VMInterface, VirtualMachine +from virtualization.models import Cluster, ClusterType, Interface as VMInterface, VirtualMachine from tenancy.models import Tenant, TenantGroup @@ -415,16 +415,16 @@ class IPAddressTestCase(TestCase): Tenant.objects.bulk_create(tenants) ipaddresses = ( - IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), - IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), - IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), - IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), - IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), - IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), - IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), - IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), - IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), - IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), + IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), + IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), + IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), + IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), + IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), + IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vm_interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), + IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vm_interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), + IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vm_interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), + IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE), ) IPAddress.objects.bulk_create(ipaddresses) @@ -486,12 +486,13 @@ class IPAddressTestCase(TestCase): params = {'virtual_machine': [vms[0].name, vms[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_interface(self): - interfaces = Interface.objects.all()[:2] - params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'interface': ['Interface 1', 'Interface 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + # TODO: Restore filtering by interface + # def test_interface(self): + # interfaces = Interface.objects.all()[:2] + # params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # params = {'interface': ['Interface 1', 'Interface 2']} + # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_assigned_to_interface(self): params = {'assigned_to_interface': 'true'} diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 06090e768..eb7f05e8f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -236,7 +236,6 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tenant': None, 'status': IPAddressStatusChoices.STATUS_RESERVED, 'role': IPAddressRoleChoices.ROLE_ANYCAST, - 'interface': None, 'nat_inside': None, 'dns_name': 'example', 'description': 'A new IP address', diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 98fe1d73d..20355bab3 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -517,7 +517,7 @@ class PrefixIPAddressesView(ObjectView): # Find all IPAddresses belonging to this Prefix ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' + 'vrf', 'primary_ip4_for', 'primary_ip6_for' ) # Add available IP addresses to the table if requested @@ -593,7 +593,7 @@ class PrefixBulkDeleteView(BulkDeleteView): class IPAddressListView(ObjectListView): queryset = IPAddress.objects.prefetch_related( - 'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine' + 'vrf__tenant', 'tenant', 'nat_inside' ) filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm @@ -607,49 +607,47 @@ class IPAddressView(ObjectView): ipaddress = get_object_or_404(self.queryset, pk=pk) - # Parent prefixes table - parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( - vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip) - ).prefetch_related( - 'site', 'role' - ) - parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False) - parent_prefixes_table.exclude = ('vrf',) - - # Duplicate IPs table - duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter( - vrf=ipaddress.vrf, address=str(ipaddress.address) - ).exclude( - pk=ipaddress.pk - ).prefetch_related( - 'nat_inside', 'interface__device' - ) - # Exclude anycast IPs if this IP is anycast - if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST: - duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST) - duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) - - # Related IP table - related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related( - 'interface__device' - ).exclude( - address=str(ipaddress.address) - ).filter( - vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) - ) - related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) - } - RequestConfig(request, paginate).configure(related_ips_table) + # # Parent prefixes table + # parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( + # vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip) + # ).prefetch_related( + # 'site', 'role' + # ) + # parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False) + # parent_prefixes_table.exclude = ('vrf',) + # + # # Duplicate IPs table + # duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter( + # vrf=ipaddress.vrf, address=str(ipaddress.address) + # ).exclude( + # pk=ipaddress.pk + # ).prefetch_related( + # 'nat_inside' + # ) + # # Exclude anycast IPs if this IP is anycast + # if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST: + # duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST) + # duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) + # + # # Related IP table + # related_ips = IPAddress.objects.restrict(request.user, 'view').exclude( + # address=str(ipaddress.address) + # ).filter( + # vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) + # ) + # related_ips_table = tables.IPAddressTable(related_ips, orderable=False) + # + # paginate = { + # 'paginator_class': EnhancedPaginator, + # 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + # } + # RequestConfig(request, paginate).configure(related_ips_table) return render(request, 'ipam/ipaddress.html', { 'ipaddress': ipaddress, - 'parent_prefixes_table': parent_prefixes_table, - 'duplicate_ips_table': duplicate_ips_table, - 'related_ips_table': related_ips_table, + # 'parent_prefixes_table': parent_prefixes_table, + # 'duplicate_ips_table': duplicate_ips_table, + # 'related_ips_table': related_ips_table, }) @@ -699,9 +697,7 @@ class IPAddressAssignView(ObjectView): if form.is_valid(): - addresses = self.queryset.prefetch_related( - 'vrf', 'tenant', 'interface__device', 'interface__virtual_machine' - ) + addresses = self.queryset.prefetch_related('vrf', 'tenant') # Limit to 100 results addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100] table = tables.IPAddressAssignTable(addresses) @@ -734,7 +730,7 @@ class IPAddressBulkImportView(BulkImportView): class IPAddressBulkEditView(BulkEditView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable form = forms.IPAddressBulkEditForm @@ -742,7 +738,7 @@ class IPAddressBulkEditView(BulkEditView): class IPAddressBulkDeleteView(BulkDeleteView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device') + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 6eba1a5e6..ff83061cf 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -120,8 +120,8 @@ Assignment - {% if ipaddress.interface %} - {{ ipaddress.interface.parent }} ({{ ipaddress.interface }}) + {% if ipaddress.assigned_object %} + {{ ipaddress.assigned_object.parent }} ({{ ipaddress.assigned_object }}) {% else %} {% endif %} @@ -132,8 +132,8 @@ {% if ipaddress.nat_inside %} {{ ipaddress.nat_inside }} - {% if ipaddress.nat_inside.interface %} - ({{ ipaddress.nat_inside.interface.parent }}) + {% if ipaddress.nat_inside.assigned_object %} + ({{ ipaddress.nat_inside.assigned_object.parent }}) {% endif %} {% else %} None diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index b8e1f92c5..e71a23668 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -267,10 +267,3 @@ class InterfaceTestCase( # 'untagged_vlan': vlans[0].pk, # 'tagged_vlans': [v.pk for v in vlans[1:4]], } - - cls.csv_data = ( - "device,name,type", - "Device 1,Interface 4,1000BASE-T (1GE)", - "Device 1,Interface 5,1000BASE-T (1GE)", - "Device 1,Interface 6,1000BASE-T (1GE)", - ) From f2b26282b873a7c09b404bc4a74aeea49309e035 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 15:09:16 -0400 Subject: [PATCH 024/137] Disable VM interface bulk creation testing --- netbox/virtualization/forms.py | 9 +++++---- netbox/virtualization/tests/test_views.py | 2 +- netbox/virtualization/views.py | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 5789dff88..4c62df344 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -356,11 +356,10 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): for family in [4, 6]: ip_choices = [(None, '---------')] # Collect interface IPs - interface_pks = self.instance.interfaces.values_list('id', flat=True) interface_ips = IPAddress.objects.prefetch_related('interface').filter( address__family=family, assigned_object_type=ContentType.objects.get_for_model(Interface), - assigned_object_id__in=interface_pks + assigned_object_id__in=self.instance.interfaces.values_list('id', flat=True) ) if interface_ips: ip_choices.append( @@ -370,7 +369,9 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( - address__family=family, nat_inside__interface__virtual_machine=self.instance + address__family=family, + nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), + nat_inside__assigned_object_id__in=self.instance.interfaces.values_list('id', flat=True) ) if nat_ips: ip_choices.append( @@ -622,7 +623,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): super().__init__(*args, **kwargs) # Add current site to VLANs query params - site = getattr(self.instance.virtual_machine, 'site', None) + site = self.instance.virtual_machine.site if site is not None: # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index e71a23668..fba3e0eac 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -195,7 +195,7 @@ class InterfaceTestCase( ViewTestCases.GetObjectViewTestCase, ViewTestCases.EditObjectViewTestCase, ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.BulkCreateObjectsViewTestCase, + # ViewTestCases.BulkCreateObjectsViewTestCase, ViewTestCases.BulkEditObjectsViewTestCase, ViewTestCases.BulkDeleteObjectsViewTestCase, ): diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index bd700d16b..65fdddd85 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -331,6 +331,7 @@ class InterfaceView(ObjectView): }) +# TODO: This should not use ComponentCreateView class InterfaceCreateView(ComponentCreateView): queryset = Interface.objects.all() form = forms.InterfaceCreateForm From 380a5cf8a7d805f2d8b2f4ae4baa63517aaa27cc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 15:12:35 -0400 Subject: [PATCH 025/137] Fix IP choices for DeviceForm --- netbox/dcim/forms.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2109f0784..7eda7d8cd 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1816,18 +1816,22 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ip_choices = [(None, '---------')] # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member - interface_ids = self.instance.vc_interfaces.values('pk') + interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True) # Collect interface IPs interface_ips = IPAddress.objects.prefetch_related('interface').filter( - address__family=family, interface_id__in=interface_ids + address__family=family, + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=interface_ids ) if interface_ips: ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( - address__family=family, nat_inside__interface__in=interface_ids + address__family=family, + nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), + nat_inside__assigned_object_id__in=interface_ids ) if nat_ips: ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] From 37564d630a19af78b88c6dda92d72e620ed40eef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 15:17:01 -0400 Subject: [PATCH 026/137] Misc test fixes --- netbox/dcim/api/views.py | 2 +- netbox/dcim/tests/test_filters.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 324edcb49..24f553f0e 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -484,7 +484,7 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet): class InterfaceViewSet(CableTraceMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags' + 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ipaddresses', 'tags' ).filter( device__isnull=False ) diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index 6c261f025..d4504d586 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase): # Assign primary IPs for filtering ipaddresses = ( - IPAddress(address='192.0.2.1/24', interface=interfaces[0]), - IPAddress(address='192.0.2.2/24', interface=interfaces[1]), + IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), + IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]), ) IPAddress.objects.bulk_create(ipaddresses) Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0]) From 7b24984280171df30d2b3fd438103f83653dcd8d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 15:39:57 -0400 Subject: [PATCH 027/137] Update IPAddressSerializer --- netbox/ipam/api/serializers.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e92006096..d7f70f113 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,7 @@ from collections import OrderedDict +from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator @@ -9,10 +11,12 @@ from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( - ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, + ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, + get_serializer_for_model, ) from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * @@ -228,18 +232,31 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) - interface = IPAddressInterfaceSerializer(required=False, allow_null=True) + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS), + required=False + ) + assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) class Meta: model = IPAddress fields = [ - 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside', - 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', ] read_only_fields = ['family'] + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, obj): + if obj.assigned_object is None: + return None + serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.assigned_object, context=context).data + class AvailableIPSerializer(serializers.Serializer): """ From b5d53fa850903043e73bbea8234249fc3aebfa09 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 15:49:09 -0400 Subject: [PATCH 028/137] Fix schema deconstruction for NaturalOrderingField --- .../migrations/0093_device_component_ordering.py | 16 ++++++++-------- .../0094_device_component_template_ordering.py | 14 +++++++------- .../migrations/0095_primary_model_ordering.py | 6 +++--- .../dcim/migrations/0096_interface_ordering.py | 4 ++-- netbox/utilities/fields.py | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py index 4e3c941a1..925694958 100644 --- a/netbox/dcim/migrations/0093_device_component_ordering.py +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -79,42 +79,42 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='consoleserverport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='devicebay', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='frontport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='inventoryitem', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='poweroutlet', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='powerport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='rearport', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_consoleports, diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py index 24fe98e94..70acd3189 100644 --- a/netbox/dcim/migrations/0094_device_component_template_ordering.py +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -75,37 +75,37 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='consoleserverporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='devicebaytemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='frontporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='poweroutlettemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='powerporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='rearporttemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_consoleporttemplates, diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py index 6225a9b73..2d6be72c8 100644 --- a/netbox/dcim/migrations/0095_primary_model_ordering.py +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -43,17 +43,17 @@ class Migration(migrations.Migration): migrations.AddField( model_name='device', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), ), migrations.AddField( model_name='rack', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.AddField( model_name='site', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( code=naturalize_sites, diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py index f1622f504..7b2663c95 100644 --- a/netbox/dcim/migrations/0096_interface_ordering.py +++ b/netbox/dcim/migrations/0096_interface_ordering.py @@ -35,12 +35,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interface', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), ), migrations.AddField( model_name='interfacetemplate', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), ), migrations.RunPython( code=naturalize_interfacetemplates, diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 4eb19f539..a9b851def 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -68,6 +68,6 @@ class NaturalOrderingField(models.CharField): return ( self.name, 'utilities.fields.NaturalOrderingField', - ['target_field'], + [self.target_field], kwargs, ) From 27796bbd08fa4f3c741ef5ae278d3ce8e2107f85 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 15:58:47 -0400 Subject: [PATCH 029/137] Add queryset to IPAddressBulkCreateView --- netbox/ipam/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 98fe1d73d..0447960c2 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -719,6 +719,7 @@ class IPAddressDeleteView(ObjectDeleteView): class IPAddressBulkCreateView(BulkCreateView): + queryset = IPAddress.objects.all() form = forms.IPAddressBulkCreateForm model_form = forms.IPAddressBulkAddForm pattern_target = 'address' From 40938f0c8a657825ce37758c8b5785b961218131 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 16:13:18 -0400 Subject: [PATCH 030/137] Retain ip_addresses name for related IPAddress objects --- netbox/dcim/api/views.py | 2 +- netbox/dcim/models/device_components.py | 2 +- netbox/dcim/views.py | 2 +- netbox/ipam/models.py | 2 +- netbox/virtualization/models.py | 2 +- netbox/virtualization/views.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 24f553f0e..324edcb49 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -484,7 +484,7 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet): class InterfaceViewSet(CableTraceMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ipaddresses', 'tags' + 'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags' ).filter( device__isnull=False ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8f945622a..8724994f5 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -686,7 +686,7 @@ class Interface(CableTermination, ComponentModel, BaseInterface): blank=True, verbose_name='Tagged VLANs' ) - ipaddresses = GenericRelation( + ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', object_id_field='assigned_object_id' diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9b19734e6..6aad18bd3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1442,7 +1442,7 @@ class InterfaceView(ObjectView): # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( - data=interface.ipaddresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 11eddb07a..c7baba435 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -2,7 +2,7 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8d4d5d889..de6073b4f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -405,7 +405,7 @@ class Interface(BaseInterface): blank=True, verbose_name='Tagged VLANs' ) - ipaddresses = GenericRelation( + ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', object_id_field='assigned_object_id' diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 65fdddd85..4b37b5a66 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -306,7 +306,7 @@ class InterfaceView(ObjectView): # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( - data=interface.ipaddresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) From fc2d08c407e9477af6d0aa3da43742e080f8f693 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 16:27:13 -0400 Subject: [PATCH 031/137] Set related_query_name for GenericRelations to IPAddress --- netbox/dcim/forms.py | 6 ++---- netbox/dcim/models/device_components.py | 3 ++- netbox/ipam/filters.py | 9 +++------ netbox/ipam/forms.py | 11 ++++------- netbox/virtualization/forms.py | 7 ++----- netbox/virtualization/models.py | 3 ++- 6 files changed, 15 insertions(+), 24 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7eda7d8cd..c8d445c1c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1821,8 +1821,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Collect interface IPs interface_ips = IPAddress.objects.prefetch_related('interface').filter( address__family=family, - assigned_object_type=ContentType.objects.get_for_model(Interface), - assigned_object_id__in=interface_ids + interface__in=interface_ids ) if interface_ips: ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] @@ -1830,8 +1829,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( address__family=family, - nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), - nat_inside__assigned_object_id__in=interface_ids + nat_inside__interface__in=interface_ids ) if nat_ips: ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8724994f5..fdbeeade8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -689,7 +689,8 @@ class Interface(CableTermination, ComponentModel, BaseInterface): ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', - object_id_field='assigned_object_id' + object_id_field='assigned_object_id', + related_query_name='interface' ) tags = TaggableManager(through=TaggedItem) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index aa3fa885b..c9012cd3a 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,6 +1,5 @@ import django_filters import netaddr -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from netaddr.core import AddrFormatError @@ -12,7 +11,7 @@ from utilities.filters import ( BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, ) -from virtualization.models import Interface as VMInterface, VirtualMachine +from virtualization.models import VirtualMachine from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -386,8 +385,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, for device in devices: interface_ids.extend(device.vc_interfaces.values_list('id', flat=True)) return queryset.filter( - assigned_object_type=ContentType.objects.get_for_model(Interface), - assigned_object_id__in=interface_ids + interface__in=interface_ids ) def filter_virtual_machine(self, queryset, name, value): @@ -398,8 +396,7 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, for vm in virtual_machines: interface_ids.extend(vm.interfaces.values_list('id', flat=True)) return queryset.filter( - assigned_object_type=ContentType.objects.get_for_model(VMInterface), - assigned_object_id__in=interface_ids + vm_interface__in=interface_ids ) def _assigned_to_interface(self, queryset, name, value): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index a66a306da..3ffbc2d4f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,8 +1,7 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator -from dcim.models import Device, Interface, Rack, Region, Site +from dcim.models import Device, Rack, Region, Site from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -15,7 +14,7 @@ from utilities.forms import ( ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import Interface as VMInterface, VirtualMachine +from virtualization.models import VirtualMachine from .choices import * from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -1196,13 +1195,11 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm): # Limit IP address choices to those assigned to interfaces of the parent device/VM if self.instance.device: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - assigned_object_type=ContentType.objects.get_for_model(Interface), - assigned_object_id__in=self.instance.device.vc_interfaces.values_list('id', flat=True) + interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True) ) elif self.instance.virtual_machine: self.fields['ipaddresses'].queryset = IPAddress.objects.filter( - assigned_object_type=ContentType.objects.get_for_model(VMInterface), - assigned_object_id__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True) + vm_interface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True) ) else: self.fields['ipaddresses'].choices = [] diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 4c62df344..500de821b 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from dcim.choices import InterfaceModeChoices @@ -358,8 +357,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Collect interface IPs interface_ips = IPAddress.objects.prefetch_related('interface').filter( address__family=family, - assigned_object_type=ContentType.objects.get_for_model(Interface), - assigned_object_id__in=self.instance.interfaces.values_list('id', flat=True) + vm_interface__in=self.instance.interfaces.values_list('id', flat=True) ) if interface_ips: ip_choices.append( @@ -370,8 +368,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( address__family=family, - nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), - nat_inside__assigned_object_id__in=self.instance.interfaces.values_list('id', flat=True) + nat_inside__vm_interface__in=self.instance.interfaces.values_list('id', flat=True) ) if nat_ips: ip_choices.append( diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index de6073b4f..2adf821a5 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -408,7 +408,8 @@ class Interface(BaseInterface): ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', - object_id_field='assigned_object_id' + object_id_field='assigned_object_id', + related_query_name='vm_interface' ) tags = TaggableManager( through=TaggedItem, From bb6be8e3d3337bd59c5ef8c9e23c5b0c124a1e5d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Jun 2020 16:36:06 -0400 Subject: [PATCH 032/137] Disable editing assigned interface under IPAddress form --- netbox/templates/ipam/ipaddress_edit.html | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d8902595a..8583ec160 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -28,21 +28,28 @@ {% render_field form.tenant %}
- {% if obj.interface %} + {% if obj.assigned_object %}
Interface Assignment
- + +
+
+ +
- {% render_field form.interface %} {% render_field form.primary_for_parent %}
From d1bd010e057af3cde79ad3fd64921d4ff28cbad1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Jun 2020 12:50:22 -0400 Subject: [PATCH 033/137] Fix Interface tag replication in schema migration --- netbox/virtualization/migrations/0016_replicate_interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/virtualization/migrations/0016_replicate_interfaces.py b/netbox/virtualization/migrations/0016_replicate_interfaces.py index c259b4140..640e9b02f 100644 --- a/netbox/virtualization/migrations/0016_replicate_interfaces.py +++ b/netbox/virtualization/migrations/0016_replicate_interfaces.py @@ -5,7 +5,7 @@ from django.db import migrations def replicate_interfaces(apps, schema_editor): ContentType = apps.get_model('contenttypes', 'ContentType') - TaggedItem = apps.get_model('taggit', 'TaggedItem') + TaggedItem = apps.get_model('extras', 'TaggedItem') Interface = apps.get_model('dcim', 'Interface') IPAddress = apps.get_model('ipam', 'IPAddress') VMInterface = apps.get_model('virtualization', 'Interface') From 75354a8a78861b7c85bfa0b8a558d4e8a26cbe2b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Jun 2020 13:16:21 -0400 Subject: [PATCH 034/137] Rename Interface to VMInterface --- netbox/ipam/tests/test_filters.py | 2 +- netbox/virtualization/api/serializers.py | 4 ++-- netbox/virtualization/api/views.py | 4 ++-- netbox/virtualization/filters.py | 4 ++-- netbox/virtualization/forms.py | 10 ++++---- ...{0015_interface.py => 0015_vminterface.py} | 3 ++- .../migrations/0016_replicate_interfaces.py | 10 ++++---- netbox/virtualization/models.py | 5 ++-- netbox/virtualization/tables.py | 4 ++-- netbox/virtualization/tests/test_api.py | 24 +++++++++---------- netbox/virtualization/tests/test_filters.py | 20 ++++++++-------- netbox/virtualization/tests/test_views.py | 12 +++++----- netbox/virtualization/views.py | 20 ++++++++-------- 13 files changed, 62 insertions(+), 60 deletions(-) rename netbox/virtualization/migrations/{0015_interface.py => 0015_vminterface.py} (96%) diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 8382ae409..db9241480 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, from ipam.choices import * from ipam.filters import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from virtualization.models import Cluster, ClusterType, Interface as VMInterface, VirtualMachine +from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index a437a000c..d2a13ce7d 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -10,7 +10,7 @@ from ipam.models import VLAN from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .nested_serializers import * @@ -106,7 +106,7 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): ) class Meta: - model = Interface + model = VMInterface fields = [ 'id', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index bcff543a8..8d16e08e1 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -5,7 +5,7 @@ from extras.api.views import CustomFieldModelViewSet from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters -from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from . import serializers @@ -72,7 +72,7 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): class InterfaceViewSet(ModelViewSet): - queryset = Interface.objects.filter( + queryset = VMInterface.objects.filter( virtual_machine__isnull=False ).prefetch_related( 'virtual_machine', 'tags' diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index dd1c3e4b2..50bde1b3f 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -9,7 +9,7 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface __all__ = ( 'ClusterFilterSet', @@ -222,7 +222,7 @@ class InterfaceFilterSet(BaseFilterSet): ) class Meta: - model = Interface + model = VMInterface fields = ['id', 'name', 'enabled', 'mtu'] def search(self, queryset, name, value): diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 500de821b..ec4b28f04 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -19,7 +19,7 @@ from utilities.forms import ( StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface # @@ -600,7 +600,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): ) class Meta: - model = Interface + model = VMInterface fields = [ 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', 'tagged_vlans', @@ -717,7 +717,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), + queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput() ) virtual_machine = forms.ModelChoiceField( @@ -786,7 +786,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): class InterfaceFilterForm(forms.Form): - model = Interface + model = VMInterface enabled = forms.NullBooleanField( required=False, widget=StaticSelect2( @@ -816,7 +816,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): class InterfaceBulkCreateForm( - form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), + form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']), VirtualMachineBulkAddComponentForm ): pass diff --git a/netbox/virtualization/migrations/0015_interface.py b/netbox/virtualization/migrations/0015_vminterface.py similarity index 96% rename from netbox/virtualization/migrations/0015_interface.py rename to netbox/virtualization/migrations/0015_vminterface.py index 7ad22eeb8..fcda6b4f3 100644 --- a/netbox/virtualization/migrations/0015_interface.py +++ b/netbox/virtualization/migrations/0015_vminterface.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Interface', + name='VMInterface', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ('name', models.CharField(max_length=64)), @@ -38,6 +38,7 @@ class Migration(migrations.Migration): options={ 'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')), 'unique_together': {('virtual_machine', 'name')}, + 'verbose_name': 'interface', }, ), ] diff --git a/netbox/virtualization/migrations/0016_replicate_interfaces.py b/netbox/virtualization/migrations/0016_replicate_interfaces.py index 640e9b02f..2df483e78 100644 --- a/netbox/virtualization/migrations/0016_replicate_interfaces.py +++ b/netbox/virtualization/migrations/0016_replicate_interfaces.py @@ -8,10 +8,10 @@ def replicate_interfaces(apps, schema_editor): TaggedItem = apps.get_model('extras', 'TaggedItem') Interface = apps.get_model('dcim', 'Interface') IPAddress = apps.get_model('ipam', 'IPAddress') - VMInterface = apps.get_model('virtualization', 'Interface') + VMInterface = apps.get_model('virtualization', 'VMInterface') interface_ct = ContentType.objects.get_for_model(Interface) - vm_interface_ct = ContentType.objects.get_for_model(VMInterface) + vminterface_ct = ContentType.objects.get_for_model(VMInterface) # Replicate dcim.Interface instances assigned to VirtualMachines original_interfaces = Interface.objects.filter(virtual_machine__isnull=False) @@ -35,12 +35,12 @@ def replicate_interfaces(apps, schema_editor): TaggedItem.objects.filter( content_type=interface_ct, object_id=interface.pk ).update( - content_type=vm_interface_ct, object_id=vm_interface.pk + content_type=vminterface_ct, object_id=vm_interface.pk ) # Update any assigned IPAddresses IPAddress.objects.filter(assigned_object_id=interface.pk).update( - assigned_object_type=vm_interface_ct, + assigned_object_type=vminterface_ct, assigned_object_id=vm_interface.pk ) @@ -59,7 +59,7 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0037_ipaddress_assignment'), - ('virtualization', '0015_interface'), + ('virtualization', '0015_vminterface'), ] operations = [ diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 2adf821a5..1ef4832a8 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -20,8 +20,8 @@ __all__ = ( 'Cluster', 'ClusterGroup', 'ClusterType', - 'Interface', 'VirtualMachine', + 'VMInterface', ) @@ -381,7 +381,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # @extras_features('graphs', 'export_templates', 'webhooks') -class Interface(BaseInterface): +class VMInterface(BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, @@ -423,6 +423,7 @@ class Interface(BaseInterface): ] class Meta: + verbose_name = 'interface' ordering = ('virtual_machine', CollateAsChar('_name')) unique_together = ('virtual_machine', 'name') diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 97831a458..e06714e85 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn -from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface CLUSTERTYPE_ACTIONS = """ @@ -175,5 +175,5 @@ class VirtualMachineDetailTable(VirtualMachineTable): class InterfaceTable(BaseTable): class Meta(BaseTable.Meta): - model = Interface + model = VMInterface fields = ('name', 'enabled', 'description') diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 3027211f2..bc1b3332c 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -4,7 +4,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases -from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface class AppTest(APITestCase): @@ -203,15 +203,15 @@ class InterfaceTest(APITestCase): clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype) self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') - self.interface1 = Interface.objects.create( + self.interface1 = VMInterface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 1' ) - self.interface2 = Interface.objects.create( + self.interface2 = VMInterface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 2' ) - self.interface3 = Interface.objects.create( + self.interface3 = VMInterface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 3' ) @@ -254,8 +254,8 @@ class InterfaceTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 4) - interface4 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(VMInterface.objects.count(), 4) + interface4 = VMInterface.objects.get(pk=response.data['id']) self.assertEqual(interface4.virtual_machine_id, data['virtual_machine']) self.assertEqual(interface4.name, data['name']) @@ -272,7 +272,7 @@ class InterfaceTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 4) + self.assertEqual(VMInterface.objects.count(), 4) self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine']) self.assertEqual(response.data['name'], data['name']) self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) @@ -298,7 +298,7 @@ class InterfaceTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 6) + self.assertEqual(VMInterface.objects.count(), 6) self.assertEqual(response.data[0]['name'], data[0]['name']) self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[2]['name'], data[2]['name']) @@ -332,7 +332,7 @@ class InterfaceTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Interface.objects.count(), 6) + self.assertEqual(VMInterface.objects.count(), 6) for i in range(0, 3): self.assertEqual(response.data[i]['name'], data[i]['name']) self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) @@ -348,8 +348,8 @@ class InterfaceTest(APITestCase): response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Interface.objects.count(), 3) - interface1 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(VMInterface.objects.count(), 3) + interface1 = VMInterface.objects.get(pk=response.data['id']) self.assertEqual(interface1.name, data['name']) def test_delete_interface(self): @@ -358,4 +358,4 @@ class InterfaceTest(APITestCase): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Interface.objects.count(), 2) + self.assertEqual(VMInterface.objects.count(), 2) diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index 562ed9901..9fe6b61d5 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -4,7 +4,7 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from virtualization.choices import * from virtualization.filters import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface class ClusterTypeTestCase(TestCase): @@ -260,11 +260,11 @@ class VirtualMachineTestCase(TestCase): VirtualMachine.objects.bulk_create(vms) interfaces = ( - Interface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'), - Interface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'), - Interface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'), + VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'), + VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'), + VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'), ) - Interface.objects.bulk_create(interfaces) + VMInterface.objects.bulk_create(interfaces) def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} @@ -366,7 +366,7 @@ class VirtualMachineTestCase(TestCase): class InterfaceTestCase(TestCase): - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() filterset = InterfaceFilterSet @classmethod @@ -394,11 +394,11 @@ class InterfaceTestCase(TestCase): VirtualMachine.objects.bulk_create(vms) interfaces = ( - Interface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'), - Interface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'), - Interface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'), + VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'), + VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'), + VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'), ) - Interface.objects.bulk_create(interfaces) + VMInterface.objects.bulk_create(interfaces) def test_id(self): id_list = self.queryset.values_list('id', flat=True)[:2] diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index fba3e0eac..2a8cc8ca8 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -5,7 +5,7 @@ from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN from utilities.testing import ViewTestCases from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -199,7 +199,7 @@ class InterfaceTestCase( ViewTestCases.BulkEditObjectsViewTestCase, ViewTestCases.BulkDeleteObjectsViewTestCase, ): - model = Interface + model = VMInterface @classmethod def setUpTestData(cls): @@ -214,10 +214,10 @@ class InterfaceTestCase( ) VirtualMachine.objects.bulk_create(virtualmachines) - Interface.objects.bulk_create([ - Interface(virtual_machine=virtualmachines[0], name='Interface 1'), - Interface(virtual_machine=virtualmachines[0], name='Interface 2'), - Interface(virtual_machine=virtualmachines[0], name='Interface 3'), + VMInterface.objects.bulk_create([ + VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'), + VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'), + VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'), ]) vlans = ( diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4b37b5a66..bb2d8b9bf 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -14,7 +14,7 @@ from utilities.views import ( ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine +from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface # @@ -236,7 +236,7 @@ class VirtualMachineView(ObjectView): def get(self, request, pk): virtualmachine = get_object_or_404(self.queryset, pk=pk) - interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) + interfaces = VMInterface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) return render(request, 'virtualization/virtualmachine.html', { @@ -290,7 +290,7 @@ class VirtualMachineBulkDeleteView(BulkDeleteView): # class InterfaceListView(ObjectListView): - queryset = Interface.objects.prefetch_related('virtual_machine', 'virtual_machine__tenant', 'cable') + queryset = VMInterface.objects.prefetch_related('virtual_machine', 'virtual_machine__tenant', 'cable') filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable @@ -298,7 +298,7 @@ class InterfaceListView(ObjectListView): class InterfaceView(ObjectView): - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() def get(self, request, pk): @@ -333,30 +333,30 @@ class InterfaceView(ObjectView): # TODO: This should not use ComponentCreateView class InterfaceCreateView(ComponentCreateView): - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() form = forms.InterfaceCreateForm model_form = forms.InterfaceForm template_name = 'virtualization/virtualmachine_component_add.html' class InterfaceEditView(ObjectEditView): - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() model_form = forms.InterfaceForm template_name = 'virtualization/interface_edit.html' class InterfaceDeleteView(ObjectDeleteView): - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() class InterfaceBulkEditView(BulkEditView): - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() table = tables.InterfaceTable form = forms.InterfaceBulkEditForm class InterfaceBulkDeleteView(BulkDeleteView): - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() table = tables.InterfaceTable @@ -368,7 +368,7 @@ class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView): parent_model = VirtualMachine parent_field = 'virtual_machine' form = forms.InterfaceBulkCreateForm - queryset = Interface.objects.all() + queryset = VMInterface.objects.all() model_form = forms.InterfaceForm filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable From 25d6bbf6593aa32553a546782e960cb8a1159fb0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Jun 2020 14:38:45 -0400 Subject: [PATCH 035/137] Update view and permission names for VMInterface --- netbox/ipam/tables.py | 2 +- netbox/templates/dcim/inc/interface.html | 4 +-- netbox/templates/dcim/interface.html | 4 +-- .../templates/virtualization/interface.html | 4 +-- .../virtualization/interface_edit.html | 2 +- .../virtualization/virtualmachine.html | 6 ++-- .../virtualization/virtualmachine_list.html | 2 +- .../virtualization/api/nested_serializers.py | 2 +- netbox/virtualization/models.py | 2 +- netbox/virtualization/tests/test_api.py | 36 +++++++++---------- netbox/virtualization/urls.py | 14 ++++---- 11 files changed, 39 insertions(+), 39 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 8f731b7ae..064b8d7ce 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -168,7 +168,7 @@ VLAN_MEMBER_UNTAGGED = """ VLAN_MEMBER_ACTIONS = """ {% if perms.dcim.change_interface %} - + {% endif %} """ diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 2fe970fd7..640fca338 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -166,7 +166,7 @@ {% endif %} - + {% endif %} @@ -176,7 +176,7 @@ {% else %} - + {% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 5714c8940..b4485edae 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -17,12 +17,12 @@
{% if perms.dcim.change_interface %} - + Edit {% endif %} {% if perms.dcim.delete_interface %} - + Delete {% endif %} diff --git a/netbox/templates/virtualization/interface.html b/netbox/templates/virtualization/interface.html index 15b432a3f..8c3cb47ff 100644 --- a/netbox/templates/virtualization/interface.html +++ b/netbox/templates/virtualization/interface.html @@ -17,12 +17,12 @@
{% if perms.dcim.change_interface %} - + Edit {% endif %} {% if perms.dcim.delete_interface %} - + Delete {% endif %} diff --git a/netbox/templates/virtualization/interface_edit.html b/netbox/templates/virtualization/interface_edit.html index 437b960c9..6b0313284 100644 --- a/netbox/templates/virtualization/interface_edit.html +++ b/netbox/templates/virtualization/interface_edit.html @@ -21,7 +21,7 @@ {% block buttons %} {% if obj.pk %} - + {% else %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ea8f4fedb..b3ac51f37 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -297,18 +297,18 @@ - {% endif %} {% if interfaces and perms.dcim.delete_interface %} - {% endif %} {% if perms.dcim.add_interface %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 74839b250..f8ee77626 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -7,7 +7,7 @@ Add Components
{% endif %} diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 47b7e6442..6e7a7c460 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -57,7 +57,7 @@ class NestedVirtualMachineSerializer(WritableNestedSerializer): class NestedInterfaceSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer(read_only=True) class Meta: diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 1ef4832a8..24e5f4e87 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -431,7 +431,7 @@ class VMInterface(BaseInterface): return self.name def get_absolute_url(self): - return reverse('virtualization:interface', kwargs={'pk': self.pk}) + return reverse('virtualization:vminterface', kwargs={'pk': self.pk}) def to_csv(self): return ( diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index bc1b3332c..c307d6da6 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -221,22 +221,22 @@ class InterfaceTest(APITestCase): self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3) def test_get_interface(self): - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('virtualization.view_interface') + url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk}) + self.add_permissions('virtualization.view_vminterface') response = self.client.get(url, **self.header) self.assertEqual(response.data['name'], self.interface1.name) def test_list_interfaces(self): - url = reverse('virtualization-api:interface-list') - self.add_permissions('virtualization.view_interface') + url = reverse('virtualization-api:vminterface-list') + self.add_permissions('virtualization.view_vminterface') response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) def test_list_interfaces_brief(self): - url = reverse('virtualization-api:interface-list') - self.add_permissions('virtualization.view_interface') + url = reverse('virtualization-api:vminterface-list') + self.add_permissions('virtualization.view_vminterface') response = self.client.get('{}?brief=1'.format(url), **self.header) self.assertEqual( @@ -249,8 +249,8 @@ class InterfaceTest(APITestCase): 'virtual_machine': self.virtualmachine.pk, 'name': 'Test Interface 4', } - url = reverse('virtualization-api:interface-list') - self.add_permissions('virtualization.add_interface') + url = reverse('virtualization-api:vminterface-list') + self.add_permissions('virtualization.add_vminterface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -267,8 +267,8 @@ class InterfaceTest(APITestCase): 'untagged_vlan': self.vlan3.id, 'tagged_vlans': [self.vlan1.id, self.vlan2.id], } - url = reverse('virtualization-api:interface-list') - self.add_permissions('virtualization.add_interface') + url = reverse('virtualization-api:vminterface-list') + self.add_permissions('virtualization.add_vminterface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -293,8 +293,8 @@ class InterfaceTest(APITestCase): 'name': 'Test Interface 6', }, ] - url = reverse('virtualization-api:interface-list') - self.add_permissions('virtualization.add_interface') + url = reverse('virtualization-api:vminterface-list') + self.add_permissions('virtualization.add_vminterface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -327,8 +327,8 @@ class InterfaceTest(APITestCase): 'tagged_vlans': [self.vlan1.id], }, ] - url = reverse('virtualization-api:interface-list') - self.add_permissions('virtualization.add_interface') + url = reverse('virtualization-api:vminterface-list') + self.add_permissions('virtualization.add_vminterface') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) @@ -343,8 +343,8 @@ class InterfaceTest(APITestCase): 'virtual_machine': self.virtualmachine.pk, 'name': 'Test Interface X', } - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('virtualization.change_interface') + url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk}) + self.add_permissions('virtualization.change_vminterface') response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -353,8 +353,8 @@ class InterfaceTest(APITestCase): self.assertEqual(interface1.name, data['name']) def test_delete_interface(self): - url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('virtualization.delete_interface') + url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk}) + self.add_permissions('virtualization.delete_vminterface') response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 4e29f861a..b4aae617b 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -51,12 +51,12 @@ urlpatterns = [ path('virtual-machines//services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'), # VM interfaces - path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), - path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path('interfaces//', views.InterfaceView.as_view(), name='interface'), - path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), - path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), - path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), + path('interfaces/add/', views.InterfaceCreateView.as_view(), name='vminterface_add'), + path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'), + path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'), + path('interfaces//', views.InterfaceView.as_view(), name='vminterface'), + path('interfaces//edit/', views.InterfaceEditView.as_view(), name='vminterface_edit'), + path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='vminterface_delete'), + path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'), ] From 5ad5994b9d276e24e2b3155ab51643fc922c15ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 23 Jun 2020 15:09:32 -0400 Subject: [PATCH 036/137] Update interface view templates --- netbox/templates/dcim/interface.html | 24 ++-- .../templates/virtualization/interface.html | 120 ------------------ .../templates/virtualization/vminterface.html | 100 +++++++++++++++ ...erface_edit.html => vminterface_edit.html} | 0 netbox/virtualization/urls.py | 3 +- netbox/virtualization/views.py | 18 +-- 6 files changed, 121 insertions(+), 144 deletions(-) delete mode 100644 netbox/templates/virtualization/interface.html create mode 100644 netbox/templates/virtualization/vminterface.html rename netbox/templates/virtualization/{interface_edit.html => vminterface_edit.html} (100%) diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index b4485edae..5165169ff 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -5,29 +5,25 @@
{% if perms.dcim.change_interface %} - + Edit {% endif %} {% if perms.dcim.delete_interface %} - + Delete {% endif %}
-

{% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}

+

{% block title %}{{ interface.device }} / {{ interface.name }}{% endblock %}

{% endif %} - {% if perms.dcim.add_virtualchassis %} - - {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index a97c42e4f..c1ad82c5d 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -9,7 +9,9 @@
@@ -63,7 +65,17 @@ Domain {{ virtualchassis.domain|placeholder }} - + + + Master + + {% if virtualchassis.master %} + {{ virtualchassis.master }} + {% else %} + + {% endif %} + + {% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html new file mode 100644 index 000000000..07b17f378 --- /dev/null +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -0,0 +1,22 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Virtual Chassis
+
+ {% render_field form.name %} + {% render_field form.domain %} + {% render_field form.tags %} +
+
+
+
Member Devices
+
+ {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.members %} + {% render_field form.initial_position %} +
+
+{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 4704ef613..6aa80f910 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -144,6 +144,11 @@ Platforms + {% if perms.dcim.add_virtualchassis %} +
+ +
+ {% endif %} Virtual Chassis
  • From 36cf40f25cf8ad6b231cba3a2b8d387cced0abd6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Jun 2020 15:29:25 -0400 Subject: [PATCH 054/137] Enable CSV import for virtual chassis --- netbox/dcim/forms.py | 13 +++++++++++++ netbox/dcim/tests/test_views.py | 13 +++++++++++-- netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 7 +++++++ netbox/templates/inc/nav_menu.html | 1 + 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6f51160fa..02b6eaa6e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4306,6 +4306,19 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm nullable_fields = ['domain'] +class VirtualChassisCSVForm(CSVModelForm): + master = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Master device' + ) + + class Meta: + model = VirtualChassis + fields = VirtualChassis.csv_headers + + class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualChassis q = forms.CharField( diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 23e65eb05..5c5e46853 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1578,7 +1578,6 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): name='Device Role', slug='device-role-1' ) - # Create 9 member Devices devices = ( Device(device_type=device_type, device_role=device_role, name='Device 1', site=site), Device(device_type=device_type, device_role=device_role, name='Device 2', site=site), @@ -1589,10 +1588,13 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): Device(device_type=device_type, device_role=device_role, name='Device 7', site=site), Device(device_type=device_type, device_role=device_role, name='Device 8', site=site), Device(device_type=device_type, device_role=device_role, name='Device 9', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 10', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 11', site=site), + Device(device_type=device_type, device_role=device_role, name='Device 12', site=site), ) Device.objects.bulk_create(devices) - # Create three VirtualChassis with two members each + # Create three VirtualChassis with three members each vc1 = VirtualChassis.objects.create(name='VC1', master=devices[0], domain='domain-1') Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=vc1, vc_position=1) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2) @@ -1616,6 +1618,13 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'form-MAX_NUM_FORMS': 1000, } + cls.csv_data = ( + "name,domain,master", + "VC4,Domain 4,Device 10", + "VC5,Domain 5,Device 11", + "VC6,Domain 6,Device 12", + ) + cls.bulk_edit_data = { 'domain': 'domain-x', } diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index a0d6bdc92..347ac7064 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -321,6 +321,7 @@ urlpatterns = [ # Virtual chassis path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), + path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'), path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'), path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'), path('virtual-chassis//', views.VirtualChassisView.as_view(), name='virtualchassis'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f6fe7cf74..840b9890f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2304,6 +2304,13 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL }) +class VirtualChassisBulkImportView(BulkImportView): + queryset = VirtualChassis.objects.all() + model_form = forms.VirtualChassisCSVForm + table = tables.VirtualChassisTable + default_return_url = 'dcim:virtualchassis_list' + + class VirtualChassisBulkEditView(BulkEditView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 6aa80f910..f22baf7cc 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -147,6 +147,7 @@ {% if perms.dcim.add_virtualchassis %}
    +
    {% endif %} Virtual Chassis From e2398c8c0e2932d6d0adad9dd4c6033c66d394d5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Jun 2020 15:57:52 -0400 Subject: [PATCH 055/137] Fix signal logic --- 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 556cde6a5..aab82e502 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -13,9 +13,10 @@ def assign_virtualchassis_master(instance, created, **kwargs): When a VirtualChassis is created, automatically assign its master device (if any) to the VC. """ if created and instance.master: - instance.master.virtual_chassis = instance - instance.master.vc_position = 1 - instance.master.save() + master = Device.objects.get(pk=instance.master.pk) + master.virtual_chassis = instance + master.vc_position = 1 + master.save() @receiver(pre_delete, sender=VirtualChassis) From 2303034c92277746219f80e134cc8ea58c292182 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Jun 2020 16:22:37 -0400 Subject: [PATCH 056/137] Changelog for #2018 --- docs/release-notes/version-2.9.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 01d409290..5b98dabb0 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -10,6 +10,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo ### Enhancements +* [#2018](https://github.com/netbox-community/netbox/issues/2018) - Add `name` field to virtual chassis model * [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object * [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations @@ -35,6 +36,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * A `label` field has been added to all device components and component templates. * The IP address model now uses a generic foreign key to refer to the assigned interface. The `interface` field on the serializer has been replaced with `assigned_object_type` and `assigned_object_id` for write operations. If one exists, the assigned interface is available as `assigned_object`. * The serialized representation of a virtual machine interface now includes only relevant fields: `type`, `lag`, `mgmt_only`, `connected_endpoint_type`, `connected_endpoint`, and `cable` are no longer included. +* dcim.VirtualChassis: Added a mandatory `name` field ### Other Changes @@ -43,3 +45,5 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens. * Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead). * Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`). +* Virtual chassis are now created by navigating to `/dcim/virtual-chassis/add` rather than via the devices list. +* A name is required when creating a virtual chassis. From ba138de53bd2f529aaf8ba9b904b9002da955ed8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 24 Jun 2020 16:27:44 -0400 Subject: [PATCH 057/137] Fix display of tags --- netbox/templates/extras/inc/tags_panel.html | 2 +- netbox/utilities/tables.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/extras/inc/tags_panel.html b/netbox/templates/extras/inc/tags_panel.html index 7eeab8ec9..2024d4ab7 100644 --- a/netbox/templates/extras/inc/tags_panel.html +++ b/netbox/templates/extras/inc/tags_panel.html @@ -4,7 +4,7 @@ Tags
    - {% for tag in tags.unrestricted %} + {% for tag in tags.all %} {% tag tag url %} {% empty %} No tags assigned diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 5e277e633..10e408b43 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -151,7 +151,7 @@ class TagColumn(tables.TemplateColumn): Display a list of tags assigned to the object. """ template_code = """ - {% for tag in value.all.unrestricted %} + {% for tag in value.all %} {% include 'utilities/templatetags/tag.html' %} {% empty %} From 68ef5177f0519d1bb889a51974fdd5f075c8b0f0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 10:48:21 -0400 Subject: [PATCH 058/137] Introduce template filters for checking dynamic permissions --- netbox/utilities/templatetags/perms.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 netbox/utilities/templatetags/perms.py diff --git a/netbox/utilities/templatetags/perms.py b/netbox/utilities/templatetags/perms.py new file mode 100644 index 000000000..f1bbf7549 --- /dev/null +++ b/netbox/utilities/templatetags/perms.py @@ -0,0 +1,30 @@ +from django import template + +register = template.Library() + + +def _check_permission(user, instance, action): + return user.has_perm( + perm=f'{instance._meta.app_label}.{action}_{instance._meta.model_name}', + obj=instance + ) + + +@register.filter() +def can_view(user, instance): + return _check_permission(user, instance, 'view') + + +@register.filter() +def can_add(user, instance): + return _check_permission(user, instance, 'add') + + +@register.filter() +def can_change(user, instance): + return _check_permission(user, instance, 'change') + + +@register.filter() +def can_delete(user, instance): + return _check_permission(user, instance, 'delete') From 2f19350ff57a3d538a6ca4477d2084303e05d734 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 10:49:30 -0400 Subject: [PATCH 059/137] Tweak url_name template filter to work with URLs which need a PK --- netbox/utilities/templatetags/helpers.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index a70e917d8..425a2fca2 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -5,7 +5,6 @@ import re import yaml from django import template from django.conf import settings -from django.urls import NoReverseMatch, reverse from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown @@ -79,14 +78,7 @@ def url_name(model, action): """ Return the URL name for the given model and action, or None if invalid. """ - url_name = '{}:{}_{}'.format(model._meta.app_label, model._meta.model_name, action) - try: - # Validate and return the URL name. We don't return the actual URL yet because many of the templates - # are written to pass a name to {% url %}. - reverse(url_name) - return url_name - except NoReverseMatch: - return None + return '{}:{}_{}'.format(model._meta.app_label, model._meta.model_name, action) @register.filter() From 909ddd653c3072c2fcad8bb27115015ee15a8dc4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 10:53:00 -0400 Subject: [PATCH 060/137] Extend ObjectView to provide a default get() method --- netbox/utilities/views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index cf282a8c0..0fdb2f89e 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -127,6 +127,25 @@ class ObjectView(ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') + def get_template_name(self): + """ + Return self.template_name if set. Otherwise, resolve the template path by model app_label and name. + """ + if hasattr(self, 'template_name'): + return self.template_name + model_opts = self.queryset.model._meta + return f'{model_opts.app_label}/{model_opts.model_name}.html' + + def get(self, request, pk): + """ + Generic GET handler for accessing an object by PK + """ + instance = get_object_or_404(self.queryset, pk=pk) + + return render(request, self.get_template_name(), { + 'instance': instance, + }) + class ObjectListView(ObjectPermissionRequiredMixin, View): """ From ecf40e1525c07ca029d4a5ff8d4c251707d7cccd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 11:00:25 -0400 Subject: [PATCH 061/137] Add/update device component templates --- netbox/templates/dcim/consoleport.html | 95 +++++++++++++++++ netbox/templates/dcim/consoleserverport.html | 95 +++++++++++++++++ netbox/templates/dcim/device_component.html | 39 +++++++ netbox/templates/dcim/devicebay.html | 62 +++++++++++ netbox/templates/dcim/frontport.html | 83 +++++++++++++++ netbox/templates/dcim/interface.html | 79 ++++---------- netbox/templates/dcim/poweroutlet.html | 103 +++++++++++++++++++ netbox/templates/dcim/powerport.html | 103 +++++++++++++++++++ netbox/templates/dcim/rearport.html | 77 ++++++++++++++ 9 files changed, 679 insertions(+), 57 deletions(-) create mode 100644 netbox/templates/dcim/consoleport.html create mode 100644 netbox/templates/dcim/consoleserverport.html create mode 100644 netbox/templates/dcim/device_component.html create mode 100644 netbox/templates/dcim/devicebay.html create mode 100644 netbox/templates/dcim/frontport.html create mode 100644 netbox/templates/dcim/poweroutlet.html create mode 100644 netbox/templates/dcim/powerport.html create mode 100644 netbox/templates/dcim/rearport.html diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html new file mode 100644 index 000000000..f1f2c4c1c --- /dev/null +++ b/netbox/templates/dcim/consoleport.html @@ -0,0 +1,95 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Console Port +
    + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
    Device + {{ instance.connected_endpoint.device }} +
    Name + {{ instance.connected_endpoint.name }} +
    Type{{ instance.connected_endpoint.get_type_display|placeholder }}
    Description{{ instance.connected_endpoint.description|placeholder }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html new file mode 100644 index 000000000..8d7ca0b43 --- /dev/null +++ b/netbox/templates/dcim/consoleserverport.html @@ -0,0 +1,95 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Console Server Port +
    + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
    Device + {{ instance.connected_endpoint.device }} +
    Name + {{ instance.connected_endpoint.name }} +
    Type{{ instance.connected_endpoint.get_type_display|placeholder }}
    Description{{ instance.connected_endpoint.description|placeholder }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html new file mode 100644 index 000000000..d2a1ad660 --- /dev/null +++ b/netbox/templates/dcim/device_component.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% load helpers %} +{% load perms %} + +{% block header %} + +
    + {% if request.user|can_change:instance %} + + Edit + + {% endif %} + {% if request.user|can_delete:instance %} + + Delete + + {% endif %} +
    +

    {% block title %}{{ instance.device }} / {{ instance }}{% endblock %}

    + +{% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html new file mode 100644 index 000000000..b257cd471 --- /dev/null +++ b/netbox/templates/dcim/devicebay.html @@ -0,0 +1,62 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Device Bay +
    + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} +
    +
    +
    +
    + Installed Device +
    + {% if instance.installed_device %} + {% with device=instance.installed_device %} + + + + + + + + + +
    Device + {{ device }} +
    Device Type{{ device.device_type }}
    + {% endwith %} + {% else %} +
    + None +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html new file mode 100644 index 000000000..33ce03eb8 --- /dev/null +++ b/netbox/templates/dcim/frontport.html @@ -0,0 +1,83 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Front Port +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Rear Port + {{ instance.rear_port }} +
    Rear Port Position{{ instance.rear_port_position }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + + + + + + + + +
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.cable.status %} + {{ instance.cable.get_status_display }} + {% else %} + {{ instance.cable.get_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 5165169ff..b3163c413 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -1,41 +1,6 @@ -{% extends 'base.html' %} +{% extends 'dcim/device_component.html' %} {% load helpers %} -{% block header %} -
    -
    - -
    -
    -
    - {% if perms.dcim.change_interface %} - - Edit - - {% endif %} - {% if perms.dcim.delete_interface %} - - Delete - - {% endif %} -
    -

    {% block title %}{{ interface.device }} / {{ interface.name }}{% endblock %}

    - -{% endblock %} - {% block content %}
    @@ -47,25 +12,25 @@ Device - {{ interface.device }} + {{ instance.device }} Name - {{ interface.name }} + {{ instance.name }} Label - {{ interface.label|placeholder }} + {{ instance.label|placeholder }} Type - {{ interface.get_type_display }} + {{ instance.get_type_display }} Enabled - {% if interface.enabled %} + {% if instance.enabled %} {% else %} @@ -75,8 +40,8 @@ LAG - {% if interface.lag%} - {{ interface.lag }} + {% if instance.lag%} + {{ instance.lag }} {% else %} None {% endif %} @@ -84,31 +49,31 @@ Description - {{ interface.description|placeholder }} + {{ instance.description|placeholder }} MTU - {{ interface.mtu|placeholder }} + {{ instance.mtu|placeholder }} MAC Address - {{ interface.mac_address|placeholder }} + {{ instance.mac_address|placeholder }} 802.1Q Mode - {{ interface.get_mode_display }} + {{ instance.get_mode_display }}
    - {% include 'extras/inc/tags_panel.html' with tags=interface.tags.all %} + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
    - {% if interface.is_connectable %} + {% if instance.is_connectable %}
    Connection
    - {% if interface.cable %} + {% if instance.cable %} {% if connected_interface %} @@ -182,8 +147,8 @@ @@ -191,10 +156,10 @@ @@ -206,7 +171,7 @@ {% endif %} {% endif %} - {% if interface.is_lag %} + {% if instance.is_lag %}
    LAG Members
    Cable - {{ interface.cable }} - + {{ instance.cable }} +
    Connection Status - {% if interface.connection_status %} - {{ interface.get_connection_status_display }} + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} {% else %} - {{ interface.get_connection_status_display }} + {{ instance.get_connection_status_display }} {% endif %}
    @@ -218,7 +183,7 @@ - {% for member in interface.member_interfaces.all %} + {% for member in instance.member_interfaces.all %} {# Type #} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 0d649f812..dcf168ae7 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -11,7 +11,8 @@ {# Name #} {# Type #} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index 70ce7e8df..ee6a66d8f 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -9,7 +9,8 @@ {# Name #} {# Status #} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index 12915f64d..f267479f3 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -10,7 +10,8 @@ {# Name #} {# Type #} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 1c0630310..d9a77d647 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -11,7 +11,8 @@ {# Name #} {# Type #} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 045b25dfd..c3293e959 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -2,7 +2,8 @@ {# Name #} {# Type #} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index 73ccd6b70..c1e5482d0 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -10,7 +10,8 @@ {# Name #} {# Type #} From 103a44991aed8246e2792d8f54da1f0725bba0e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 12:22:21 -0400 Subject: [PATCH 067/137] Changelog for #4788 --- docs/release-notes/version-2.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 5b98dabb0..39d763bb5 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -14,6 +14,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object * [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations +* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components ### Configuration Changes From 5aa2a6eefe022593ddc334f9902aaf101fa927c6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 13:27:01 -0400 Subject: [PATCH 068/137] Add plugin buttons & content to device component views --- netbox/templates/dcim/consoleport.html | 8 + netbox/templates/dcim/consoleserverport.html | 8 + netbox/templates/dcim/device_component.html | 2 + netbox/templates/dcim/devicebay.html | 8 + netbox/templates/dcim/frontport.html | 8 + netbox/templates/dcim/interface.html | 420 ++++++++++--------- netbox/templates/dcim/poweroutlet.html | 8 + netbox/templates/dcim/powerport.html | 8 + netbox/templates/dcim/rearport.html | 8 + 9 files changed, 272 insertions(+), 206 deletions(-) diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index f1f2c4c1c..63916bcc5 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -1,5 +1,6 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} +{% load plugins %} {% block content %}
    @@ -34,6 +35,7 @@
    {{ member.device }} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html new file mode 100644 index 000000000..519bd01df --- /dev/null +++ b/netbox/templates/dcim/poweroutlet.html @@ -0,0 +1,103 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Power Outlet +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Description{{ instance.description|placeholder }}
    Power Port{{ instance.power_port }}
    Feed Leg{{ instance.get_feed_leg_display }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
    Device + {{ instance.connected_endpoint.device }} +
    Name + {{ instance.connected_endpoint.name }} +
    Type{{ instance.connected_endpoint.get_type_display|placeholder }}
    Description{{ instance.connected_endpoint.description|placeholder }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html new file mode 100644 index 000000000..e7c103c9d --- /dev/null +++ b/netbox/templates/dcim/powerport.html @@ -0,0 +1,103 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Power Port +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Description{{ instance.description|placeholder }}
    Maximum Draw{{ instance.maximum_draw|placeholder }}
    Allocated Draw{{ instance.allocated_draw|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
    Device + {{ instance.connected_endpoint.device }} +
    Name + {{ instance.connected_endpoint.name }} +
    Type{{ instance.connected_endpoint.get_type_display|placeholder }}
    Description{{ instance.connected_endpoint.description|placeholder }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html new file mode 100644 index 000000000..480f26d2c --- /dev/null +++ b/netbox/templates/dcim/rearport.html @@ -0,0 +1,77 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + Rear Port +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Positions{{ instance.positions }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + + + + + + + + +
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.cable.status %} + {{ instance.cable.get_status_display }} + {% else %} + {{ instance.cable.get_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    +
    +
    +{% endblock %} From b08d9a5a8e06bb1c2051991923c6d62495f66fc4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 11:01:18 -0400 Subject: [PATCH 062/137] Add individual views for device components --- netbox/dcim/models/device_components.py | 16 ++++++++---- netbox/dcim/urls.py | 34 +++++++++++++++++-------- netbox/dcim/views.py | 30 +++++++++++++++++++++- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 30a276c7d..d94e2484f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -268,7 +268,7 @@ class ConsolePort(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:consoleport', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -325,7 +325,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:consoleserverport', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -408,7 +408,7 @@ class PowerPort(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:powerport', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -560,7 +560,7 @@ class PowerOutlet(CableTermination, ComponentModel): unique_together = ('device', 'name') def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:poweroutlet', kwargs={'pk': self.pk}) def to_csv(self): return ( @@ -881,6 +881,9 @@ class FrontPort(CableTermination, ComponentModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:frontport', kwargs={'pk': self.pk}) + def to_csv(self): return ( self.device.identifier, @@ -946,6 +949,9 @@ class RearPort(CableTermination, ComponentModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:rearport', kwargs={'pk': self.pk}) + def to_csv(self): return ( self.device.identifier, @@ -1005,7 +1011,7 @@ class DeviceBay(ComponentModel): return '{} - {}'.format(self.device.name, self.name) def get_absolute_url(self): - return self.device.get_absolute_url() + return reverse('dcim:devicebay', kwargs={'pk': self.pk}) def to_csv(self): return ( diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 347ac7064..2014427b7 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -4,9 +4,9 @@ from extras.views import ObjectChangeLogView, ImageAttachmentEditView from ipam.views import ServiceEditView from . import views from .models import ( - Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, - PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, - VirtualChassis, + Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface, + Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, + RearPort, Region, Site, VirtualChassis, ) app_name = 'dcim' @@ -189,10 +189,12 @@ urlpatterns = [ path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'), # TODO: Bulk rename, disconnect views for ConsolePorts path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'), path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports @@ -203,10 +205,12 @@ urlpatterns = [ path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path('console-server-ports//', views.ConsoleServerPortView.as_view(), name='consoleserverport'), path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports @@ -216,10 +220,12 @@ urlpatterns = [ path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'), # TODO: Bulk rename, disconnect views for PowerPorts path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path('power-ports//', views.PowerPortView.as_view(), name='powerport'), path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets @@ -230,10 +236,12 @@ urlpatterns = [ path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path('power-outlets//', views.PowerOutletView.as_view(), name='poweroutlet'), path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces @@ -244,12 +252,12 @@ urlpatterns = [ path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('interfaces//', views.InterfaceView.as_view(), name='interface'), path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports @@ -260,10 +268,12 @@ urlpatterns = [ path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path('front-ports//', views.FrontPortView.as_view(), name='frontport'), path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports @@ -274,10 +284,12 @@ urlpatterns = [ path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path('rear-ports//', views.RearPortView.as_view(), name='rearport'), path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), + path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Device bays @@ -287,8 +299,10 @@ urlpatterns = [ path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'), path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), + path('device-bays//', views.DeviceBayView.as_view(), name='devicebay'), path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), + path('device-bays//changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}), path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 840b9890f..4cf787e3e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1165,6 +1165,10 @@ class ConsolePortListView(ObjectListView): action_buttons = ('import', 'export') +class ConsolePortView(ObjectView): + queryset = ConsolePort.objects.all() + + class ConsolePortCreateView(ComponentCreateView): queryset = ConsolePort.objects.all() form = forms.ConsolePortCreateForm @@ -1214,6 +1218,10 @@ class ConsoleServerPortListView(ObjectListView): action_buttons = ('import', 'export') +class ConsoleServerPortView(ObjectView): + queryset = ConsoleServerPort.objects.all() + + class ConsoleServerPortCreateView(ComponentCreateView): queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortCreateForm @@ -1273,6 +1281,10 @@ class PowerPortListView(ObjectListView): action_buttons = ('import', 'export') +class PowerPortView(ObjectView): + queryset = PowerPort.objects.all() + + class PowerPortCreateView(ComponentCreateView): queryset = PowerPort.objects.all() form = forms.PowerPortCreateForm @@ -1322,6 +1334,10 @@ class PowerOutletListView(ObjectListView): action_buttons = ('import', 'export') +class PowerOutletView(ObjectView): + queryset = PowerOutlet.objects.all() + + class PowerOutletCreateView(ComponentCreateView): queryset = PowerOutlet.objects.all() form = forms.PowerOutletCreateForm @@ -1409,7 +1425,7 @@ class InterfaceView(ObjectView): ) return render(request, 'dcim/interface.html', { - 'interface': interface, + 'instance': interface, 'connected_interface': interface._connected_interface, 'connected_circuittermination': interface._connected_circuittermination, 'ipaddress_table': ipaddress_table, @@ -1477,6 +1493,10 @@ class FrontPortListView(ObjectListView): action_buttons = ('import', 'export') +class FrontPortView(ObjectView): + queryset = FrontPort.objects.all() + + class FrontPortCreateView(ComponentCreateView): queryset = FrontPort.objects.all() form = forms.FrontPortCreateForm @@ -1536,6 +1556,10 @@ class RearPortListView(ObjectListView): action_buttons = ('import', 'export') +class RearPortView(ObjectView): + queryset = RearPort.objects.all() + + class RearPortCreateView(ComponentCreateView): queryset = RearPort.objects.all() form = forms.RearPortCreateForm @@ -1597,6 +1621,10 @@ class DeviceBayListView(ObjectListView): action_buttons = ('import', 'export') +class DeviceBayView(ObjectView): + queryset = DeviceBay.objects.all() + + class DeviceBayCreateView(ComponentCreateView): queryset = DeviceBay.objects.all() form = forms.DeviceBayCreateForm From 3badfd756c597a1a1f59609ae25a3efe9d6c88fd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 11:04:42 -0400 Subject: [PATCH 063/137] Extend DeviceComponentViewTestCase to include GetObjectViewTestCase --- netbox/dcim/tests/test_views.py | 16 +++++++++++----- netbox/utilities/testing/views.py | 1 + netbox/virtualization/tests/test_views.py | 5 +---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 5c5e46853..079f26fdf 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1194,10 +1194,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): ) -class InterfaceTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.DeviceComponentViewTestCase, -): +class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = Interface @classmethod @@ -1425,7 +1422,16 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): ) -class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): +# TODO: Convert to DeviceComponentViewTestCase? +class InventoryItemTestCase( + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkCreateObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = InventoryItem @classmethod diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 774ceac85..2cf32616c 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -917,6 +917,7 @@ class ViewTestCases: maxDiff = None class DeviceComponentViewTestCase( + GetObjectViewTestCase, EditObjectViewTestCase, DeleteObjectViewTestCase, ListObjectsViewTestCase, diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 408558779..ec4159dd4 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -189,10 +189,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class VMInterfaceTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.DeviceComponentViewTestCase, -): +class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = VMInterface @classmethod From 2001cfe864e0b80e13ac9ac666f82182c7077a05 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 11:56:31 -0400 Subject: [PATCH 064/137] Update and simplify device component tables --- netbox/dcim/tables.py | 238 +++++++++--------------------------------- netbox/dcim/views.py | 50 +++++---- 2 files changed, 75 insertions(+), 213 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 6aa41ab44..9979bea1a 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -490,18 +490,6 @@ class ConsolePortTemplateTable(ComponentTemplateTable): empty_text = "None" -class ConsolePortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = ConsolePort - fields = ('device', 'name', 'description') - empty_text = False - - class ConsoleServerPortTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleserverporttemplate'), @@ -515,18 +503,6 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): empty_text = "None" -class ConsoleServerPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = ConsoleServerPort - fields = ('device', 'name', 'description') - empty_text = False - - class PowerPortTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('powerporttemplate'), @@ -540,18 +516,6 @@ class PowerPortTemplateTable(ComponentTemplateTable): empty_text = "None" -class PowerPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = PowerPort - fields = ('device', 'name', 'description', 'maximum_draw', 'allocated_draw') - empty_text = False - - class PowerOutletTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('poweroutlettemplate'), @@ -565,18 +529,6 @@ class PowerOutletTemplateTable(ComponentTemplateTable): empty_text = "None" -class PowerOutletImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = PowerOutlet - fields = ('device', 'name', 'description', 'power_port', 'feed_leg') - empty_text = False - - class InterfaceTemplateTable(ComponentTemplateTable): mgmt_only = BooleanColumn( verbose_name='Management Only' @@ -593,20 +545,6 @@ class InterfaceTemplateTable(ComponentTemplateTable): empty_text = "None" -class InterfaceImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = Interface - fields = ( - 'device', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode', - ) - empty_text = False - - class FrontPortTemplateTable(ComponentTemplateTable): rear_port_position = tables.Column( verbose_name='Position' @@ -623,18 +561,6 @@ class FrontPortTemplateTable(ComponentTemplateTable): empty_text = "None" -class FrontPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = FrontPort - fields = ('device', 'name', 'description', 'type', 'rear_port', 'rear_port_position') - empty_text = False - - class RearPortTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('rearporttemplate'), @@ -648,18 +574,6 @@ class RearPortTemplateTable(ComponentTemplateTable): empty_text = "None" -class RearPortImportTable(BaseTable): - device = tables.LinkColumn( - viewname='dcim:device', - args=[Accessor('device.pk')] - ) - - class Meta(BaseTable.Meta): - model = RearPort - fields = ('device', 'name', 'description', 'type', 'position') - empty_text = False - - class DeviceBayTemplateTable(ComponentTemplateTable): actions = tables.TemplateColumn( template_code=get_component_template_actions('devicebaytemplate'), @@ -855,144 +769,94 @@ class DeviceImportTable(BaseTable): # Device components # -class DeviceComponentDetailTable(BaseTable): +class DeviceComponentTable(BaseTable): pk = ToggleColumn() - device = tables.LinkColumn() - name = tables.Column(order_by=('_name',)) - cable = tables.LinkColumn() + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + order_by=('_name',) + ) + cable = tables.Column( + linkify=True + ) class Meta(BaseTable.Meta): order_by = ('device', 'name') - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') - sequence = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') -class ConsolePortTable(BaseTable): - name = tables.Column(order_by=('_name',)) +class ConsolePortTable(DeviceComponentTable): - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = ConsolePort - fields = ('name', 'label', 'type') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class ConsolePortDetailTable(DeviceComponentDetailTable): +class ConsoleServerPortTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta): - pass - - -class ConsoleServerPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('name', 'label', 'description') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class ConsoleServerPortDetailTable(DeviceComponentDetailTable): +class PowerPortTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta): - pass - - -class PowerPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = PowerPort - fields = ('name', 'label', 'type') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') -class PowerPortDetailTable(DeviceComponentDetailTable): +class PowerOutletTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta): - pass - - -class PowerOutletTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = PowerOutlet - fields = ('name', 'label', 'type', 'description') + fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') -class PowerOutletDetailTable(DeviceComponentDetailTable): - - class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta): - pass - - -class InterfaceTable(BaseTable): - - class Meta(BaseTable.Meta): - model = Interface - fields = ('name', 'label', 'type', 'lag', 'enabled', 'mgmt_only', 'description') - - -class InterfaceDetailTable(DeviceComponentDetailTable): +class InterfaceTable(DeviceComponentTable): enabled = BooleanColumn() - class Meta(DeviceComponentDetailTable.Meta, InterfaceTable.Meta): - fields = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable') - sequence = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable') + class Meta(DeviceComponentTable.Meta): + model = Interface + fields = ( + 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'description', 'cable', + ) + default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') -class FrontPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) +class FrontPortTable(DeviceComponentTable): + rear_port_position = tables.Column( + verbose_name='Position' + ) - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = FrontPort - fields = ('name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') - empty_text = "None" + fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') -class FrontPortDetailTable(DeviceComponentDetailTable): +class RearPortTable(DeviceComponentTable): - class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta): - pass - - -class RearPortTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = RearPort - fields = ('name', 'label', 'type', 'positions', 'description') - empty_text = "None" + fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') -class RearPortDetailTable(DeviceComponentDetailTable): +class DeviceBayTable(DeviceComponentTable): + installed_device = tables.Column( + linkify=True + ) - class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta): - pass - - -class DeviceBayTable(BaseTable): - name = tables.Column(order_by=('_name',)) - - class Meta(BaseTable.Meta): + class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('name', 'label', 'description') - - -class DeviceBayDetailTable(DeviceComponentDetailTable): - installed_device = tables.LinkColumn() - - class Meta(DeviceBayTable.Meta): fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description') - sequence = ('pk', 'device', 'name', 'label', 'installed_device', 'description') - exclude = ('cable',) - - -class DeviceBayImportTable(BaseTable): - device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') - installed_device = tables.LinkColumn('dcim:device', args=[Accessor('installed_device.pk')], verbose_name='Installed Device') - - class Meta(BaseTable.Meta): - model = DeviceBay - fields = ('device', 'name', 'installed_device', 'description') - empty_text = False + default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4cf787e3e..1f3b61a42 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1158,10 +1158,10 @@ class DeviceBulkDeleteView(BulkDeleteView): # class ConsolePortListView(ObjectListView): - queryset = ConsolePort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = ConsolePort.objects.prefetch_related('device', 'cable') filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm - table = tables.ConsolePortDetailTable + table = tables.ConsolePortTable action_buttons = ('import', 'export') @@ -1188,7 +1188,7 @@ class ConsolePortDeleteView(ObjectDeleteView): class ConsolePortBulkImportView(BulkImportView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortCSVForm - table = tables.ConsolePortImportTable + table = tables.ConsolePortTable default_return_url = 'dcim:consoleport_list' @@ -1211,10 +1211,10 @@ class ConsolePortBulkDeleteView(BulkDeleteView): # class ConsoleServerPortListView(ObjectListView): - queryset = ConsoleServerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = ConsoleServerPort.objects.prefetch_related('device', 'cable') filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm - table = tables.ConsoleServerPortDetailTable + table = tables.ConsoleServerPortTable action_buttons = ('import', 'export') @@ -1241,7 +1241,7 @@ class ConsoleServerPortDeleteView(ObjectDeleteView): class ConsoleServerPortBulkImportView(BulkImportView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortCSVForm - table = tables.ConsoleServerPortImportTable + table = tables.ConsoleServerPortTable default_return_url = 'dcim:consoleserverport_list' @@ -1274,10 +1274,10 @@ class ConsoleServerPortBulkDeleteView(BulkDeleteView): # class PowerPortListView(ObjectListView): - queryset = PowerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = PowerPort.objects.prefetch_related('device', 'cable') filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm - table = tables.PowerPortDetailTable + table = tables.PowerPortTable action_buttons = ('import', 'export') @@ -1304,7 +1304,7 @@ class PowerPortDeleteView(ObjectDeleteView): class PowerPortBulkImportView(BulkImportView): queryset = PowerPort.objects.all() model_form = forms.PowerPortCSVForm - table = tables.PowerPortImportTable + table = tables.PowerPortTable default_return_url = 'dcim:powerport_list' @@ -1327,10 +1327,10 @@ class PowerPortBulkDeleteView(BulkDeleteView): # class PowerOutletListView(ObjectListView): - queryset = PowerOutlet.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = PowerOutlet.objects.prefetch_related('device', 'cable') filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm - table = tables.PowerOutletDetailTable + table = tables.PowerOutletTable action_buttons = ('import', 'export') @@ -1357,7 +1357,7 @@ class PowerOutletDeleteView(ObjectDeleteView): class PowerOutletBulkImportView(BulkImportView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletCSVForm - table = tables.PowerOutletImportTable + table = tables.PowerOutletTable default_return_url = 'dcim:poweroutlet_list' @@ -1390,10 +1390,10 @@ class PowerOutletBulkDeleteView(BulkDeleteView): # class InterfaceListView(ObjectListView): - queryset = Interface.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = Interface.objects.prefetch_related('device', 'cable') filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm - table = tables.InterfaceDetailTable + table = tables.InterfaceTable action_buttons = ('import', 'export') @@ -1453,7 +1453,7 @@ class InterfaceDeleteView(ObjectDeleteView): class InterfaceBulkImportView(BulkImportView): queryset = Interface.objects.all() model_form = forms.InterfaceCSVForm - table = tables.InterfaceImportTable + table = tables.InterfaceTable default_return_url = 'dcim:interface_list' @@ -1486,10 +1486,10 @@ class InterfaceBulkDeleteView(BulkDeleteView): # class FrontPortListView(ObjectListView): - queryset = FrontPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = FrontPort.objects.prefetch_related('device', 'cable') filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm - table = tables.FrontPortDetailTable + table = tables.FrontPortTable action_buttons = ('import', 'export') @@ -1516,7 +1516,7 @@ class FrontPortDeleteView(ObjectDeleteView): class FrontPortBulkImportView(BulkImportView): queryset = FrontPort.objects.all() model_form = forms.FrontPortCSVForm - table = tables.FrontPortImportTable + table = tables.FrontPortTable default_return_url = 'dcim:frontport_list' @@ -1549,10 +1549,10 @@ class FrontPortBulkDeleteView(BulkDeleteView): # class RearPortListView(ObjectListView): - queryset = RearPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable') + queryset = RearPort.objects.prefetch_related('device', 'cable') filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm - table = tables.RearPortDetailTable + table = tables.RearPortTable action_buttons = ('import', 'export') @@ -1579,7 +1579,7 @@ class RearPortDeleteView(ObjectDeleteView): class RearPortBulkImportView(BulkImportView): queryset = RearPort.objects.all() model_form = forms.RearPortCSVForm - table = tables.RearPortImportTable + table = tables.RearPortTable default_return_url = 'dcim:rearport_list' @@ -1612,12 +1612,10 @@ class RearPortBulkDeleteView(BulkDeleteView): # class DeviceBayListView(ObjectListView): - queryset = DeviceBay.objects.prefetch_related( - 'device', 'device__site', 'installed_device', 'installed_device__site' - ) + queryset = DeviceBay.objects.prefetch_related('device', 'installed_device') filterset = filters.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm - table = tables.DeviceBayDetailTable + table = tables.DeviceBayTable action_buttons = ('import', 'export') @@ -1711,7 +1709,7 @@ class DeviceBayDepopulateView(ObjectEditView): class DeviceBayBulkImportView(BulkImportView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayCSVForm - table = tables.DeviceBayImportTable + table = tables.DeviceBayTable default_return_url = 'dcim:devicebay_list' From 8695714c657a802b16a08d6783b3ce508e5e5698 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 12:09:56 -0400 Subject: [PATCH 065/137] Fix device component changelog display --- netbox/extras/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index ff8ce75e0..af5106a33 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -296,6 +296,7 @@ class ObjectChangeLogView(View): return render(request, 'extras/object_changelog.html', { object_var: obj, + 'instance': obj, # We'll eventually standardize on 'instance` for the object variable name 'table': objectchanges_table, 'base_template': base_template, 'active_tab': 'changelog', From 0fcdd639417d606f8127ce71425a4eb93983917d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 12:21:25 -0400 Subject: [PATCH 066/137] Linkify components under device view --- netbox/templates/dcim/inc/consoleport.html | 3 ++- netbox/templates/dcim/inc/consoleserverport.html | 3 ++- netbox/templates/dcim/inc/devicebay.html | 3 ++- netbox/templates/dcim/inc/frontport.html | 3 ++- netbox/templates/dcim/inc/poweroutlet.html | 3 ++- netbox/templates/dcim/inc/powerport.html | 3 ++- netbox/templates/dcim/inc/rearport.html | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 9089f19b4..02afd8f99 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -2,7 +2,8 @@ {# Name #}
    - {{ cp }} + + {{ cp }} - {{ csp }} + + {{ csp }} - {{ devicebay.name }} + + {{ devicebay.name }} - {{ frontport }} + + {{ frontport }} - {{ po }} + + {{ po }} - {{ pp }} + + {{ pp }} - {{ rearport }} + + {{ rearport }}
    {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %}
    @@ -90,6 +92,12 @@
    {% endif %}
    + {% plugin_right_page instance %} +
    + +
    +
    + {% plugin_full_width_page instance %}
    {% endblock %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 8d7ca0b43..cdc43142e 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -1,5 +1,6 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} +{% load plugins %} {% block content %}
    @@ -34,6 +35,7 @@
    {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %}
    @@ -90,6 +92,12 @@
    {% endif %}
    + {% plugin_right_page instance %} + + +
    +
    + {% plugin_full_width_page instance %}
    {% endblock %} diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html index d2a1ad660..616655066 100644 --- a/netbox/templates/dcim/device_component.html +++ b/netbox/templates/dcim/device_component.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} {% load helpers %} {% load perms %} +{% load plugins %} {% block header %}
    @@ -14,6 +15,7 @@
    @@ -57,6 +59,12 @@
    {% endif %}
    + {% plugin_right_page instance %} + + +
    +
    + {% plugin_full_width_page instance %}
    {% endblock %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 33ce03eb8..8ab51cb30 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -1,5 +1,6 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} +{% load plugins %} {% block content %}
    @@ -44,6 +45,7 @@
    {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %}
    @@ -78,6 +80,12 @@
    {% endif %}
    + {% plugin_right_page instance %} + + +
    +
    + {% plugin_full_width_page instance %}
    {% endblock %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index b3163c413..4c0b453ad 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -1,219 +1,227 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} +{% load plugins %} {% block content %} -
    -
    -
    -
    - Interface -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Device - {{ instance.device }} -
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Enabled - {% if instance.enabled %} - - {% else %} - - {% endif %} -
    LAG - {% if instance.lag%} - {{ instance.lag }} - {% else %} - None - {% endif %} -
    Description{{ instance.description|placeholder }}
    MTU{{ instance.mtu|placeholder }}
    MAC Address{{ instance.mac_address|placeholder }}
    802.1Q Mode{{ instance.get_mode_display }}
    -
    - {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} -
    -
    - {% if instance.is_connectable %} +
    +
    - Connection + Interface
    - {% if instance.cable %} - - {% if connected_interface %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% elif connected_circuittermination %} - {% with ct=connected_circuittermination %} - - - - - - - - - - - - - {% endwith %} - {% endif %} - - - - - - - - -
    Device - {{ connected_interface.device }} -
    Name - {{ connected_interface.name }} -
    Type{{ connected_interface.get_type_display }}
    Enabled - {% if connected_interface.enabled %} - - {% else %} - - {% endif %} -
    LAG - {% if connected_interface.lag%} - {{ connected_interface.lag }} - {% else %} - None - {% endif %} -
    Description{{ connected_interface.description|placeholder }}
    MTU{{ connected_interface.mtu|placeholder }}
    MAC Address{{ connected_interface.mac_address|placeholder }}
    802.1Q Mode{{ connected_interface.get_mode_display }}
    Provider{{ ct.circuit.provider }}
    Circuit{{ ct.circuit }}
    Side{{ ct.term_side }}
    Cable - {{ instance.cable }} - - - -
    Connection Status - {% if instance.connection_status %} - {{ instance.get_connection_status_display }} - {% else %} - {{ instance.get_connection_status_display }} - {% endif %} -
    - {% else %} -
    - Not connected -
    - {% endif %} -
    - {% endif %} - {% if instance.is_lag %} -
    -
    LAG Members
    - - - - - - - - - - {% for member in instance.member_interfaces.all %} - - - - - - {% empty %} - - - - {% endfor %} - +
    ParentInterfaceType
    - {{ member.device }} - - {{ member }} - - {{ member.get_type_display }} -
    No member interfaces
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Enabled + {% if instance.enabled %} + + {% else %} + + {% endif %} +
    LAG + {% if instance.lag%} + {{ instance.lag }} + {% else %} + None + {% endif %} +
    Description{{ instance.description|placeholder }}
    MTU{{ instance.mtu|placeholder }}
    MAC Address{{ instance.mac_address|placeholder }}
    802.1Q Mode{{ instance.get_mode_display }}
    - {% endif %} + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    + {% if instance.is_connectable %} +
    +
    + Connection +
    + {% if instance.cable %} + + {% if connected_interface %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% elif connected_circuittermination %} + {% with ct=connected_circuittermination %} + + + + + + + + + + + + + {% endwith %} + {% endif %} + + + + + + + + +
    Device + {{ connected_interface.device }} +
    Name + {{ connected_interface.name }} +
    Type{{ connected_interface.get_type_display }}
    Enabled + {% if connected_interface.enabled %} + + {% else %} + + {% endif %} +
    LAG + {% if connected_interface.lag%} + {{ connected_interface.lag }} + {% else %} + None + {% endif %} +
    Description{{ connected_interface.description|placeholder }}
    MTU{{ connected_interface.mtu|placeholder }}
    MAC Address{{ connected_interface.mac_address|placeholder }}
    802.1Q Mode{{ connected_interface.get_mode_display }}
    Provider{{ ct.circuit.provider }}
    Circuit{{ ct.circuit }}
    Side{{ ct.term_side }}
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected +
    + {% endif %} +
    + {% endif %} + {% if instance.is_lag %} +
    +
    LAG Members
    + + + + + + + + + + {% for member in instance.member_interfaces.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
    ParentInterfaceType
    + {{ member.device }} + + {{ member }} + + {{ member.get_type_display }} +
    No member interfaces
    +
    + {% endif %} + {% plugin_right_page device %} +
    -
    -
    -
    - {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    +
    + {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    -
    -
    -
    - {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    + {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    -
    {% endblock %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 519bd01df..cddcffd6f 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -1,5 +1,6 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} +{% load plugins %} {% block content %}
    @@ -42,6 +43,7 @@
    {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %}
    @@ -98,6 +100,12 @@
    {% endif %}
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %}
    {% endblock %} diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index e7c103c9d..8642bd8fb 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -1,5 +1,6 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} +{% load plugins %} {% block content %}
    @@ -42,6 +43,7 @@
    {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %}
    @@ -98,6 +100,12 @@
    {% endif %}
    + {% plugin_right_page instance %} + + +
    +
    + {% plugin_full_width_page instance %}
    {% endblock %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 480f26d2c..982d53eaa 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -1,5 +1,6 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} +{% load plugins %} {% block content %}
    @@ -38,6 +39,7 @@
    {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %}
    @@ -72,6 +74,12 @@
    {% endif %}
    + {% plugin_right_page instance %} + + +
    +
    + {% plugin_full_width_page instance %}
    {% endblock %} From ec9b33ac977759a6a4671b46362227d49cb79766 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 13:36:54 -0400 Subject: [PATCH 069/137] Fix typo --- netbox/templates/dcim/interface.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 4c0b453ad..e3d67eb2c 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -206,7 +206,7 @@ {% endif %} - {% plugin_right_page device %} + {% plugin_right_page instance %}
    From 1dbae5b64c757ca2a4bf92704b1511dd57ba7640 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 14:18:29 -0400 Subject: [PATCH 070/137] Closes #4792: Add bulk rename capability for console and power ports --- docs/release-notes/version-2.9.md | 1 + netbox/dcim/forms.py | 44 +- netbox/dcim/urls.py | 6 +- netbox/dcim/views.py | 14 +- netbox/templates/dcim/device.html | 550 +++++++++++---------- netbox/templates/dcim/inc/consoleport.html | 7 + netbox/templates/dcim/inc/powerport.html | 7 + netbox/utilities/views.py | 15 +- 8 files changed, 328 insertions(+), 316 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 39d763bb5..799acbcfe 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -15,6 +15,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations * [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components +* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports ### Configuration Changes diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 02b6eaa6e..e4beaa56d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,7 +23,7 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - BulkRenameForm, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, + ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -2375,13 +2375,6 @@ class ConsoleServerPortBulkEditForm( ] -class ConsoleServerPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), @@ -2610,13 +2603,6 @@ class PowerOutletBulkEditForm( self.fields['power_port'].widget.attrs['disabled'] = True -class PowerOutletBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class PowerOutletBulkDisconnectForm(ConfirmationForm): pk = forms.ModelMultipleChoiceField( queryset=PowerOutlet.objects.all(), @@ -2922,13 +2908,6 @@ class InterfaceBulkEditForm( self.cleaned_data['tagged_vlans'] = [] -class InterfaceBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class InterfaceBulkDisconnectForm(ConfirmationForm): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), @@ -3115,13 +3094,6 @@ class FrontPortBulkEditForm( ] -class FrontPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class FrontPortBulkDisconnectForm(ConfirmationForm): pk = forms.ModelMultipleChoiceField( queryset=FrontPort.objects.all(), @@ -3245,13 +3217,6 @@ class RearPortBulkEditForm( ] -class RearPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class RearPortBulkDisconnectForm(ConfirmationForm): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), @@ -3354,13 +3319,6 @@ class DeviceBayBulkEditForm( ) -class DeviceBayBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class DeviceBayCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 2014427b7..43fa259bd 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -187,7 +187,8 @@ urlpatterns = [ path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'), - # TODO: Bulk rename, disconnect views for ConsolePorts + path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'), + # TODO: Bulk disconnect view for ConsolePorts path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'), path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), @@ -218,7 +219,8 @@ urlpatterns = [ path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'), - # TODO: Bulk rename, disconnect views for PowerPorts + path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'), + # TODO: Bulk disconnect view for PowerPorts path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), path('power-ports//', views.PowerPortView.as_view(), name='powerport'), path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1f3b61a42..7ca150e3d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1199,6 +1199,10 @@ class ConsolePortBulkEditView(BulkEditView): form = forms.ConsolePortBulkEditForm +class ConsolePortBulkRenameView(BulkRenameView): + queryset = ConsolePort.objects.all() + + class ConsolePortBulkDeleteView(BulkDeleteView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet @@ -1254,7 +1258,6 @@ class ConsoleServerPortBulkEditView(BulkEditView): class ConsoleServerPortBulkRenameView(BulkRenameView): queryset = ConsoleServerPort.objects.all() - form = forms.ConsoleServerPortBulkRenameForm class ConsoleServerPortBulkDisconnectView(BulkDisconnectView): @@ -1315,6 +1318,10 @@ class PowerPortBulkEditView(BulkEditView): form = forms.PowerPortBulkEditForm +class PowerPortBulkRenameView(BulkRenameView): + queryset = PowerPort.objects.all() + + class PowerPortBulkDeleteView(BulkDeleteView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet @@ -1370,7 +1377,6 @@ class PowerOutletBulkEditView(BulkEditView): class PowerOutletBulkRenameView(BulkRenameView): queryset = PowerOutlet.objects.all() - form = forms.PowerOutletBulkRenameForm class PowerOutletBulkDisconnectView(BulkDisconnectView): @@ -1466,7 +1472,6 @@ class InterfaceBulkEditView(BulkEditView): class InterfaceBulkRenameView(BulkRenameView): queryset = Interface.objects.all() - form = forms.InterfaceBulkRenameForm class InterfaceBulkDisconnectView(BulkDisconnectView): @@ -1529,7 +1534,6 @@ class FrontPortBulkEditView(BulkEditView): class FrontPortBulkRenameView(BulkRenameView): queryset = FrontPort.objects.all() - form = forms.FrontPortBulkRenameForm class FrontPortBulkDisconnectView(BulkDisconnectView): @@ -1592,7 +1596,6 @@ class RearPortBulkEditView(BulkEditView): class RearPortBulkRenameView(BulkRenameView): queryset = RearPort.objects.all() - form = forms.RearPortBulkRenameForm class RearPortBulkDisconnectView(BulkDisconnectView): @@ -1722,7 +1725,6 @@ class DeviceBayBulkEditView(BulkEditView): class DeviceBayBulkRenameView(BulkRenameView): queryset = DeviceBay.objects.all() - form = forms.DeviceBayBulkRenameForm class DeviceBayBulkDeleteView(BulkDeleteView): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index a42250a3d..c7f58aa4d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -326,34 +326,79 @@ {% plugin_left_page device %}
    - {% if console_ports or power_ports %} -
    -
    - Console / Power -
    - - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% endfor %} -
    - {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} -
    + + {% endif %} + {% if power_ports %} +
    + {% csrf_token %} +
    +
    + Power Ports +
    + + {% for pp in power_ports %} + {% include 'dcim/inc/powerport.html' %} + {% endfor %} +
    + +
    +
    {% endif %} {% if power_ports and poweroutlets %}
    @@ -501,262 +546,242 @@
    {% if device_bays or device.device_type.is_parent_device %} - {% if perms.dcim.delete_devicebay %} -
    + {% csrf_token %} - {% endif %} -
    -
    - Device Bays -
    - - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} +
    +
    + Device Bays +
    +
    NameStatusDescriptionInstalled Device
    + - + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} + + {% endif %} + + + + + - {% endfor %} - -
    — No device bays defined —NameStatusDescriptionInstalled Device
    - -
    - {% if perms.dcim.delete_devicebay %} -
    - {% endif %} + + + {% for devicebay in device_bays %} + {% include 'dcim/inc/devicebay.html' %} + {% empty %} + + — No device bays defined — + + {% endfor %} + + + +
    + {% endif %} {% if interfaces %} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
    + {% csrf_token %} - - {% endif %} -
    -
    - Interfaces -
    - -
    -
    - -
    -
    - - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% endfor %} - -
    NameLAGDescriptionMTUModeCableConnection
    - + {% endif %} {% if consoleserverports %} - {% if perms.dcim.delete_consoleserverport %} -
    + {% csrf_token %} - - {% endif %} -
    -
    - Console Server Ports +
    +
    + Console Server Ports +
    + + + + {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} + + {% endif %} + + + + + + + + + + {% for csp in consoleserverports %} + {% include 'dcim/inc/consoleserverport.html' %} + {% endfor %} + +
    NameTypeDescriptionCableConnection
    +
    - - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - - - - {% for csp in consoleserverports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% endfor %} - -
    NameTypeDescriptionCableConnection
    - -
    - {% if perms.dcim.delete_consoleserverport %} - - {% endif %} + {% endif %} {% if poweroutlets %} - {% if perms.dcim.delete_poweroutlet %} -
    + {% csrf_token %} - - {% endif %} -
    -
    - Power Outlets +
    +
    + Power Outlets +
    + + + + {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} + + {% endif %} + + + + + + + + + + + {% for po in poweroutlets %} + {% include 'dcim/inc/poweroutlet.html' %} + {% endfor %} + +
    NameTypeInput/LegDescriptionCableConnection
    +
    - - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - - - - - {% for po in poweroutlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% endfor %} - -
    NameTypeInput/LegDescriptionCableConnection
    - -
    - {% if perms.dcim.delete_poweroutlet %} - - {% endif %} + {% endif %} {% if front_ports %}
    {% csrf_token %} -
    Front Ports @@ -815,7 +840,6 @@ {% if rear_ports %} {% csrf_token %} -
    Rear Ports diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 02afd8f99..61b4fe045 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -1,5 +1,12 @@ + {# Checkbox #} + {% if perms.dcim.change_consoleport or perms.dcim.delete_consoleport %} + + + + {% endif %} + {# Name #} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index c3293e959..58eed145a 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -1,5 +1,12 @@ + {# Checkbox #} + {% if perms.dcim.change_powerport or perms.dcim.delete_powerport %} + + + + {% endif %} + {# Name #} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 0fdb2f89e..5785da93f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction -from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm +from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm from utilities.permissions import get_permission_for_model, resolve_permission from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror @@ -988,9 +988,20 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): An extendable view for renaming objects in bulk. """ queryset = None - form = None template_name = 'utilities/obj_bulk_rename.html' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create a new Form class from BulkRenameForm + class _Form(BulkRenameForm): + pk = ModelMultipleChoiceField( + queryset=self.queryset, + widget=MultipleHiddenInput() + ) + + self.form = _Form + def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') From 2e272132b00baa22bd585718e83fcd8509455be7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 15:43:47 -0400 Subject: [PATCH 071/137] Add test method for changelog view --- netbox/utilities/testing/views.py | 39 +++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 2cf32616c..25ef5df5e 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -177,27 +177,23 @@ class ModelViewTestCase(TestCase): def _get_url(self, action, instance=None): """ - Return the URL name for a specific action. An instance must be specified for - get/edit/delete views. + Return the URL name for a specific action and optionally a specific instance """ url_format = self._get_base_url() - if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'): + # If no instance was provided, assume we don't need a unique identifier + if instance is None: return reverse(url_format.format(action)) - elif action in ('get', 'edit', 'delete'): - if instance is None: - raise Exception("Resolving {} URL requires specifying an instance".format(action)) - # Attempt to resolve using slug first - if hasattr(self.model, 'slug'): - try: - return reverse(url_format.format(action), kwargs={'slug': instance.slug}) - except NoReverseMatch: - pass - return reverse(url_format.format(action), kwargs={'pk': instance.pk}) + # Attempt to resolve using slug as the unique identifier if one exists + if hasattr(self.model, 'slug'): + try: + return reverse(url_format.format(action), kwargs={'slug': instance.slug}) + except NoReverseMatch: + pass - else: - raise Exception("Invalid action for URL resolution: {}".format(action)) + # Default to using the numeric PK to retrieve the URL for an object + return reverse(url_format.format(action), kwargs={'pk': instance.pk}) class ViewTestCases: @@ -257,6 +253,16 @@ class ViewTestCases: # Try GET to non-permitted object self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404) + class GetObjectChangelogViewTestCase(ModelViewTestCase): + """ + View the changelog for an instance. + """ + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_get_object_changelog(self): + url = self._get_url('changelog', self.model.objects.first()) + response = self.client.get(url) + self.assertHttpStatus(response, 200) + class CreateObjectViewTestCase(ModelViewTestCase): """ Create a single new instance. @@ -879,6 +885,7 @@ class ViewTestCases: class PrimaryObjectViewTestCase( GetObjectViewTestCase, + GetObjectChangelogViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, DeleteObjectViewTestCase, @@ -893,6 +900,7 @@ class ViewTestCases: maxDiff = None class OrganizationalObjectViewTestCase( + GetObjectChangelogViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, ListObjectsViewTestCase, @@ -918,6 +926,7 @@ class ViewTestCases: class DeviceComponentViewTestCase( GetObjectViewTestCase, + GetObjectChangelogViewTestCase, EditObjectViewTestCase, DeleteObjectViewTestCase, ListObjectsViewTestCase, From 6f8f3f98b40bbfbbc8775f0ab4e18f46a94258b2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 15:58:13 -0400 Subject: [PATCH 072/137] Tweak ObjectChangeLogView to work with both restricted and unrestricted querysets --- netbox/extras/views.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index af5106a33..222332962 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -261,9 +261,11 @@ class ObjectChangeLogView(View): def get(self, request, model, **kwargs): - # Get object my model and kwargs (e.g. slug='foo') - queryset = model.objects.restrict(request.user, 'view') - obj = get_object_or_404(queryset, **kwargs) + # Handle QuerySet restriction of parent object if needed + if hasattr(model.objects, 'restrict'): + obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs) + else: + obj = get_object_or_404(model, **kwargs) # Gather all changes for this object (and its related objects) content_type = ContentType.objects.get_for_model(model) From 128327b8a3e4953234832856a8280f854e2b2f8b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 16:50:35 -0400 Subject: [PATCH 073/137] Split url_name template filter into viewname() and validated_viewname() --- netbox/templates/dcim/device_component.html | 8 ++++---- netbox/templates/utilities/obj_list.html | 6 +++--- netbox/utilities/templatetags/helpers.py | 22 ++++++++++++++++++--- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html index 616655066..9fa66502e 100644 --- a/netbox/templates/dcim/device_component.html +++ b/netbox/templates/dcim/device_component.html @@ -9,7 +9,7 @@
    @@ -17,12 +17,12 @@
    {% plugin_buttons instance %} {% if request.user|can_change:instance %} - + Edit {% endif %} {% if request.user|can_delete:instance %} - + Delete {% endif %} @@ -34,7 +34,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index 85ff050ed..47f11e1c1 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -9,10 +9,10 @@ {% endif %} {% if permissions.add and 'add' in action_buttons %} - {% add_button content_type.model_class|url_name:"add" %} + {% add_button content_type.model_class|validated_viewname:"add" %} {% endif %} {% if permissions.add and 'import' in action_buttons %} - {% import_button content_type.model_class|url_name:"import" %} + {% import_button content_type.model_class|validated_viewname:"import" %} {% endif %} {% if 'export' in action_buttons %} {% export_button content_type %} @@ -21,7 +21,7 @@

    {% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}

    - {% with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} + {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} {% if permissions.change or permissions.delete %} {% csrf_token %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 425a2fca2..e6a245a04 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -5,6 +5,7 @@ import re import yaml from django import template from django.conf import settings +from django.urls import NoReverseMatch, reverse from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown @@ -74,11 +75,26 @@ def meta(obj, attr): @register.filter() -def url_name(model, action): +def viewname(model, action): """ - Return the URL name for the given model and action, or None if invalid. + Return the view name for the given model and action. Does not perform any validation. """ - return '{}:{}_{}'.format(model._meta.app_label, model._meta.model_name, action) + return f'{model._meta.app_label}:{model._meta.model_name}_{action}' + + +@register.filter() +def validated_viewname(model, action): + """ + Return the view name for the given model and action if valid, or None if invalid. + """ + viewname = f'{model._meta.app_label}:{model._meta.model_name}_{action}' + try: + # Validate and return the view name. We don't return the actual URL yet because many of the templates + # are written to pass a name to {% url %}. + reverse(viewname) + return viewname + except NoReverseMatch: + return None @register.filter() From 319799b5ce4ab4908f4858e5b0c53c3626d16351 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 Jun 2020 17:08:51 -0400 Subject: [PATCH 074/137] Closes #4795: Add bulk disconnect capability for console and power ports --- docs/release-notes/version-2.9.md | 1 + netbox/dcim/forms.py | 35 ------------------------------- netbox/dcim/urls.py | 4 ++-- netbox/dcim/views.py | 28 ++++++++++++++++++------- netbox/templates/dcim/device.html | 6 ++++++ 5 files changed, 30 insertions(+), 44 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 799acbcfe..3fefd8569 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -16,6 +16,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations * [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components * [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports +* [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports ### Configuration Changes diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e4beaa56d..deb61729f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2375,13 +2375,6 @@ class ConsoleServerPortBulkEditForm( ] -class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class ConsoleServerPortCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -2603,13 +2596,6 @@ class PowerOutletBulkEditForm( self.fields['power_port'].widget.attrs['disabled'] = True -class PowerOutletBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class PowerOutletCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -2908,13 +2894,6 @@ class InterfaceBulkEditForm( self.cleaned_data['tagged_vlans'] = [] -class InterfaceBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - class InterfaceCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -3094,13 +3073,6 @@ class FrontPortBulkEditForm( ] -class FrontPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class FrontPortCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -3217,13 +3189,6 @@ class RearPortBulkEditForm( ] -class RearPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - class RearPortCSVForm(CSVModelForm): device = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 43fa259bd..45b10cd0c 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -188,7 +188,7 @@ urlpatterns = [ path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'), path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'), - # TODO: Bulk disconnect view for ConsolePorts + path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'), path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'), path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), @@ -220,7 +220,7 @@ urlpatterns = [ path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'), path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'), - # TODO: Bulk disconnect view for PowerPorts + path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'), path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), path('power-ports//', views.PowerPortView.as_view(), name='powerport'), path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7ca150e3d..3ec77d356 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction from django.db.models import Count, F -from django.forms import modelformset_factory +from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape @@ -46,9 +46,20 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View) An extendable view for disconnection console/power/interface components in bulk. """ queryset = None - form = None template_name = 'dcim/bulk_disconnect.html' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create a new Form class from ConfirmationForm + class _Form(ConfirmationForm): + pk = ModelMultipleChoiceField( + queryset=self.queryset, + widget=MultipleHiddenInput() + ) + + self.form = _Form + def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') @@ -1203,6 +1214,10 @@ class ConsolePortBulkRenameView(BulkRenameView): queryset = ConsolePort.objects.all() +class ConsolePortBulkDisconnectView(BulkDisconnectView): + queryset = ConsolePort.objects.all() + + class ConsolePortBulkDeleteView(BulkDeleteView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet @@ -1262,7 +1277,6 @@ class ConsoleServerPortBulkRenameView(BulkRenameView): class ConsoleServerPortBulkDisconnectView(BulkDisconnectView): queryset = ConsoleServerPort.objects.all() - form = forms.ConsoleServerPortBulkDisconnectForm class ConsoleServerPortBulkDeleteView(BulkDeleteView): @@ -1322,6 +1336,10 @@ class PowerPortBulkRenameView(BulkRenameView): queryset = PowerPort.objects.all() +class PowerPortBulkDisconnectView(BulkDisconnectView): + queryset = PowerPort.objects.all() + + class PowerPortBulkDeleteView(BulkDeleteView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet @@ -1381,7 +1399,6 @@ class PowerOutletBulkRenameView(BulkRenameView): class PowerOutletBulkDisconnectView(BulkDisconnectView): queryset = PowerOutlet.objects.all() - form = forms.PowerOutletBulkDisconnectForm class PowerOutletBulkDeleteView(BulkDeleteView): @@ -1476,7 +1493,6 @@ class InterfaceBulkRenameView(BulkRenameView): class InterfaceBulkDisconnectView(BulkDisconnectView): queryset = Interface.objects.all() - form = forms.InterfaceBulkDisconnectForm class InterfaceBulkDeleteView(BulkDeleteView): @@ -1538,7 +1554,6 @@ class FrontPortBulkRenameView(BulkRenameView): class FrontPortBulkDisconnectView(BulkDisconnectView): queryset = FrontPort.objects.all() - form = forms.FrontPortBulkDisconnectForm class FrontPortBulkDeleteView(BulkDeleteView): @@ -1600,7 +1615,6 @@ class RearPortBulkRenameView(BulkRenameView): class RearPortBulkDisconnectView(BulkDisconnectView): queryset = RearPort.objects.all() - form = forms.RearPortBulkDisconnectForm class RearPortBulkDeleteView(BulkDeleteView): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index c7f58aa4d..03ca0f8b4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -346,6 +346,9 @@ + {% endif %} {% if console_ports and perms.dcim.delete_consoleport %} + {% endif %} {% if power_ports and perms.dcim.delete_powerport %}
    diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index c1ad82c5d..18ec8c4e7 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -93,7 +93,7 @@ Master Priority - {% for vc_member in virtualchassis.members.all %} + {% for vc_member in members %} {{ vc_member }} diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 60b5f766a..fadd614c3 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,13 +1,13 @@ from django.contrib import messages from django.db import transaction -from django.db.models import Count +from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView -from ipam.models import Service +from ipam.models import IPAddress, Service from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView, @@ -88,6 +88,9 @@ class ClusterView(ObjectView): queryset = Cluster.objects.all() def get(self, request, pk): + self.queryset = self.queryset.prefetch_related( + Prefetch('virtual_machines', queryset=VirtualMachine.objects.restrict(request.user)) + ) cluster = get_object_or_404(self.queryset, pk=pk) devices = Device.objects.restrict(request.user, 'view').filter(cluster=cluster).prefetch_related( @@ -236,8 +239,16 @@ class VirtualMachineView(ObjectView): def get(self, request, pk): virtualmachine = get_object_or_404(self.queryset, pk=pk) - interfaces = VMInterface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) - services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine) + interfaces = VMInterface.objects.restrict(request.user, 'view').filter( + virtual_machine=virtualmachine + ).prefetch_related( + Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)) + ) + services = Service.objects.restrict(request.user, 'view').filter( + virtual_machine=virtualmachine + ).prefetch_related( + Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)) + ) return render(request, 'virtualization/virtualmachine.html', { 'virtualmachine': virtualmachine, @@ -315,7 +326,7 @@ class VMInterfaceView(ObjectView): if vminterface.untagged_vlan is not None: vlans.append(vminterface.untagged_vlan) vlans[0].tagged = False - for vlan in vminterface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'): + for vlan in vminterface.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'): vlan.tagged = True vlans.append(vlan) vlan_table = InterfaceVLANTable( From 045594759746da5135666819d916f8172962a650 Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Fri, 26 Jun 2020 18:24:04 +0200 Subject: [PATCH 083/137] 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 084/137] 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 8412f9481cacc3dac9173b97703621edd023f79f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 13:18:12 -0400 Subject: [PATCH 085/137] Force restriction of RestrictedQuerySet even for superusers --- netbox/utilities/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 50401dfd1..2e2d7f7f5 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -327,7 +327,7 @@ class ModelViewSet(_ModelViewSet): def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) - if not request.user.is_authenticated or request.user.is_superuser: + if not request.user.is_authenticated: return # TODO: Reconcile this with TokenPermissions.perms_map From edc65a6a34aeefbf4a419687a32999878e834a8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 13:59:53 -0400 Subject: [PATCH 086/137] Introduce restrict_form_fields() to automatically restrict field querysets based on user --- netbox/utilities/forms.py | 11 +++++++++++ netbox/utilities/views.py | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 59e581ff4..0d15d34df 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -15,6 +15,7 @@ from django.forms import BoundField from django.forms.models import fields_for_model from django.urls import reverse +from utilities.querysets import RestrictedQuerySet from .choices import ColorChoices, unpack_grouped_choices from .validators import EnhancedURLValidator @@ -138,6 +139,16 @@ def form_from_model(model, fields): return type('FormFromModel', (forms.Form,), form_fields) +def restrict_form_fields(form, user, action='view'): + """ + Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects + as available choices. + """ + for field in form.fields.values(): + if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet): + field.queryset = field.queryset.restrict(user, action) + + # # Widgets # diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 5785da93f..7fd41804b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -28,7 +28,7 @@ from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate from extras.querysets import CustomFieldQueryset from utilities.exceptions import AbortTransaction -from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm +from utilities.forms import BootstrapMixin, BulkRenameForm, CSVDataField, TableConfigForm, restrict_form_fields from utilities.permissions import get_permission_for_model, resolve_permission from utilities.utils import csv_format, prepare_cloned_fields from .error_handlers import handle_protectederror @@ -352,6 +352,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} form = self.model_form(instance=obj, initial=initial_data) + restrict_form_fields(form, request.user) return render(request, self.template_name, { 'obj': obj, @@ -368,6 +369,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): files=request.FILES, instance=obj ) + restrict_form_fields(form, request.user) if form.is_valid(): logger.debug("Form validation was successful") From 2c354c7f86db78c751829a81b4d4e03f301e4712 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 14:29:24 -0400 Subject: [PATCH 087/137] Fix automatic creation of UserConfig for user created via admin UI --- netbox/users/admin.py | 6 +++--- netbox/users/models.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index cc7a1b379..25703966c 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,7 +1,7 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import Group as StockGroup, User as StockUser +from django.contrib.auth.models import Group, User from django.core.exceptions import FieldError, ValidationError from extras.admin import order_content_types @@ -13,8 +13,8 @@ from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig # # Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below -admin.site.unregister(StockGroup) -admin.site.unregister(StockUser) +admin.site.unregister(Group) +admin.site.unregister(User) @admin.register(AdminGroup) diff --git a/netbox/users/models.py b/netbox/users/models.py index 7987ccb7a..bce3bd704 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -159,6 +159,7 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) +@receiver(post_save, sender=AdminUser) def create_userconfig(instance, created, **kwargs): """ Automatically create a new UserConfig when a new User is created. From 84db1adfaffd88c1e31f96fd65db21f7395b8379 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 14:48:04 -0400 Subject: [PATCH 088/137] Fix create, edit view test methods --- netbox/utilities/testing/views.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 25ef5df5e..392a26fb2 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -271,7 +271,6 @@ class ViewTestCases: """ form_data = {} - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object_without_permission(self): # Try GET without permission @@ -287,7 +286,7 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_permission(self): initial_count = self.model.objects.count() @@ -311,7 +310,7 @@ class ViewTestCases: self.assertEqual(initial_count + 1, self.model.objects.count()) self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_constrained_permission(self): initial_count = self.model.objects.count() @@ -356,7 +355,6 @@ class ViewTestCases: """ form_data = {} - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object_without_permission(self): instance = self.model.objects.first() @@ -372,7 +370,7 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_permission(self): instance = self.model.objects.first() @@ -395,7 +393,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(**request), 302) self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_constrained_permission(self): instance1, instance2 = self.model.objects.all()[:2] @@ -433,7 +431,6 @@ class ViewTestCases: """ Delete a single instance. """ - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_delete_object_without_permission(self): instance = self.model.objects.first() @@ -449,7 +446,7 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(**request), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_permission(self): instance = self.model.objects.first() @@ -473,7 +470,7 @@ class ViewTestCases: with self.assertRaises(ObjectDoesNotExist): self.model.objects.get(pk=instance.pk) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_constrained_permission(self): instance1, instance2 = self.model.objects.all()[:2] From 6128ef4b3700f7ca558057dee3c84dca4f00a971 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 15:00:47 -0400 Subject: [PATCH 089/137] Remove redundant ObjectPermissionViewTestCase --- netbox/netbox/tests/test_authentication.py | 350 --------------------- 1 file changed, 350 deletions(-) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7e5ce89b7..3ae203c29 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -168,356 +168,6 @@ class ExternalAuthenticationTestCase(TestCase): self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site'])) -class ObjectPermissionViewTestCase(TestCase): - - @classmethod - def setUpTestData(cls): - - cls.sites = ( - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2'), - Site(name='Site 3', slug='site-3'), - ) - Site.objects.bulk_create(cls.sites) - - cls.prefixes = ( - Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), - Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), - Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]), - ) - Prefix.objects.bulk_create(cls.prefixes) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_get_object(self): - - # Attempt to retrieve object without permission - response = self.client.get(self.prefixes[0].get_absolute_url()) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Retrieve permitted object - response = self.client.get(self.prefixes[0].get_absolute_url()) - self.assertHttpStatus(response, 200) - - # Attempt to retrieve non-permitted object - response = self.client.get(self.prefixes[3].get_absolute_url()) - self.assertHttpStatus(response, 404) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects(self): - - # Attempt to list objects without permission - response = self.client.get(reverse('ipam:prefix_list')) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Retrieve all objects. Only permitted objects should be returned. - response = self.client.get(reverse('ipam:prefix_list')) - self.assertHttpStatus(response, 200) - self.assertIn(str(self.prefixes[0].prefix), str(response.content)) - self.assertNotIn(str(self.prefixes[3].prefix), str(response.content)) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_create_object(self): - initial_count = Prefix.objects.count() - form_data = { - 'prefix': '10.0.9.0/24', - 'site': self.sites[1].pk, - 'status': PrefixStatusChoices.STATUS_ACTIVE, - } - - # Attempt to create an object without permission - request = { - 'path': reverse('ipam:prefix_add'), - 'data': form_data, - 'follow': False, # Do not follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - self.assertEqual(initial_count, Prefix.objects.count()) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'add'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to create a non-permitted object - request = { - 'path': reverse('ipam:prefix_add'), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count) - - # Create a permitted object - form_data['site'] = self.sites[0].pk - request = { - 'path': reverse('ipam:prefix_add'), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count + 1) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_edit_object(self): - form_data = { - 'prefix': '10.0.9.0/24', - 'site': self.sites[0].pk, - 'status': PrefixStatusChoices.STATUS_RESERVED, - } - - # Attempt to edit an object without permission - request = { - 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - 'follow': False, # Do not follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'change'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to edit a non-permitted object - request = { - 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[3].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 404) - - # Edit a permitted object - request = { - 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - prefix = Prefix.objects.get(pk=self.prefixes[0].pk) - self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_delete_object(self): - form_data = { - 'confirm': True - } - - # Attempt to delete object without permission - request = { - 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'delete'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Delete permitted object - request = { - 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) - - # Attempt to delete non-permitted object - request = { - 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[3].pk}), - 'data': form_data, - 'follow': True, # Follow 302 redirects - } - response = self.client.post(**request) - self.assertHttpStatus(response, 404) - self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_import_objects(self): - initial_count = Prefix.objects.count() - form_data = { - 'csv': "prefix,status,site\n" - "10.0.9.0/24,Active,Site 1\n" - "10.0.10.0/24,Active,Site 2\n" - "10.0.11.0/24,Active,Site 3\n", - } - - # Attempt to import objects without permission - request = { - 'path': reverse('ipam:prefix_import'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - self.assertEqual(initial_count, Prefix.objects.count()) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['add'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to create non-permitted objects - request = { - 'path': reverse('ipam:prefix_import'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count) - - # Create a permitted object - form_data = { - 'csv': "prefix,status,site\n" - "10.0.9.0/24,Active,Site 1\n" - "10.0.10.0/24,Active,Site 1\n" - "10.0.11.0/24,Active,Site 1\n", - } - request = { - 'path': reverse('ipam:prefix_import'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), initial_count + 3) - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_edit_objects(self): - form_data = { - 'pk': [p.pk for p in self.prefixes], - 'status': 'reserved', - '_apply': True, - } - - # Attempt to edit objects without permission - request = { - 'path': reverse('ipam:prefix_bulk_edit'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['change'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to edit non-permitted objects - request = { - 'path': reverse('ipam:prefix_bulk_edit'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active') - - # Edit permitted objects - form_data['pk'] = [p.pk for p in self.prefixes[:3]] - request = { - 'path': reverse('ipam:prefix_bulk_edit'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - self.assertEqual(Prefix.objects.get(pk=self.prefixes[0].pk).status, 'reserved') - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_bulk_delete_objects(self): - form_data = { - 'pk': [p.pk for p in self.prefixes], - 'confirm': True, - '_confirm': True, - } - - # Attempt to delete objects without permission - request = { - 'path': reverse('ipam:prefix_bulk_delete'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 403) - - # Assign object permission - obj_perm = ObjectPermission( - constraints={'site__name': 'Site 1'}, - actions=['view', 'delete'] - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix)) - - # Attempt to delete non-permitted object - request = { - 'path': reverse('ipam:prefix_bulk_delete'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists()) - - # Delete permitted objects - form_data['pk'] = [p.pk for p in self.prefixes[:3]] - request = { - 'path': reverse('ipam:prefix_bulk_delete'), - 'data': form_data, - } - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists()) - - class ObjectPermissionAPIViewTestCase(TestCase): client_class = APIClient From 9a1531442a9529115dca2ec83a46968f5295ff6e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 15:11:05 -0400 Subject: [PATCH 090/137] Apply restrict_form_fields() to bulk edit views --- netbox/utilities/testing/views.py | 10 ++++------ netbox/utilities/views.py | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 392a26fb2..bd0eaeccd 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -654,7 +654,6 @@ class ViewTestCases: def _get_csv_data(self): return '\n'.join(self.csv_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_import_objects_without_permission(self): data = { 'csv': self._get_csv_data(), @@ -669,7 +668,7 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(response, 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_permission(self): initial_count = self.model.objects.count() data = { @@ -691,7 +690,7 @@ class ViewTestCases: self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_constrained_permission(self): initial_count = self.model.objects.count() data = { @@ -728,7 +727,6 @@ class ViewTestCases: """ bulk_edit_data = {} - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_edit_objects_without_permission(self): pk_list = self.model.objects.values_list('pk', flat=True)[:3] data = { @@ -744,7 +742,7 @@ class ViewTestCases: with disable_warnings('django.request'): self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_permission(self): pk_list = self.model.objects.values_list('pk', flat=True)[:3] data = { @@ -768,7 +766,7 @@ class ViewTestCases: for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_constrained_permission(self): initial_instances = self.model.objects.all()[:3] pk_list = list(self.model.objects.values_list('pk', flat=True)[:3]) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 7fd41804b..a929b3af2 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -863,6 +863,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if '_apply' in request.POST: form = self.form(model, request.POST) + restrict_form_fields(form, request.user) if form.is_valid(): logger.debug("Form validation was successful") @@ -970,6 +971,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): initial_data['device_type'] = request.GET.get('device_type') form = self.form(model, initial=initial_data) + restrict_form_fields(form, request.user) # Retrieve objects being edited table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) From 40c416618ae0c3c13db3d2137e31aadf9f879e34 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 15:13:41 -0400 Subject: [PATCH 091/137] Link to cable termination objects --- netbox/templates/dcim/inc/cable_termination.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 0711ff121..1ba3d05c9 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -16,7 +16,9 @@ Component - {{ termination }} + + {{ termination }} + {% else %} {# Circuit termination #} From 5dfa80c0b910db0faa71cf7df6ae07752f2b9244 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 15:17:07 -0400 Subject: [PATCH 092/137] 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 04571ce92042d446a9f53e1a8472323b09f08bf9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 15:21:59 -0400 Subject: [PATCH 093/137] Fix the initial permissions check on create/edit view tests --- netbox/utilities/testing/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index bd0eaeccd..6152153d9 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -275,7 +275,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 POST without permission request = { @@ -360,7 +360,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 POST without permission request = { From a452e78fa65226c45a970beb4a187497207bcea1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 15:28:08 -0400 Subject: [PATCH 094/137] Use unrestricted() when compiling ObjectPermissions for user --- netbox/netbox/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 10d2d1b09..7381ca685 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -24,7 +24,7 @@ class ObjectPermissionBackend(ModelBackend): Return all permissions granted to the user by an ObjectPermission. """ # Retrieve all assigned ObjectPermissions - object_permissions = ObjectPermission.objects.filter( + object_permissions = ObjectPermission.objects.unrestricted().filter( Q(users=user_obj) | Q(groups__user=user_obj) ).prefetch_related('object_types') From c8461095c9907315adf5ba904fe3df421b77e94c Mon Sep 17 00:00:00 2001 From: Ryan Merolle Date: Fri, 26 Jun 2020 15:34:38 -0400 Subject: [PATCH 095/137] 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 8c0adc9c61763678270e38581c0b62df1f595301 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 16:15:21 -0400 Subject: [PATCH 096/137] Update test methods to call unrestricted() on RestrictedQuerySets --- netbox/utilities/testing/views.py | 107 ++++++++++++++++-------------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 6152153d9..12c811396 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -165,6 +165,14 @@ class ModelViewTestCase(TestCase): if self.model is None: raise Exception("Test case requires model to be defined") + def _get_queryset(self): + """ + Return a base queryset suitable for use in test methods. Call unrestricted() if RestrictedQuerySet is in use. + """ + if hasattr(self.model.objects, 'restrict'): + return self.model.objects.unrestricted() + return self.model.objects.all() + def _get_base_url(self): """ Return the base format for a URL for the test's model. Override this to test for a model which belongs @@ -208,12 +216,12 @@ class ViewTestCases: def test_get_object_anonymous(self): # Make the request as an unauthenticated user self.client.logout() - response = self.client.get(self.model.objects.first().get_absolute_url()) + response = self.client.get(self._get_queryset().first().get_absolute_url()) self.assertHttpStatus(response, 200) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_without_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Try GET without permission with disable_warnings('django.request'): @@ -221,7 +229,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Add model-level permission obj_perm = ObjectPermission( @@ -236,7 +244,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -259,7 +267,7 @@ class ViewTestCases: """ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_get_object_changelog(self): - url = self._get_url('changelog', self.model.objects.first()) + url = self._get_url('changelog', self._get_queryset().first()) response = self.client.get(url) self.assertHttpStatus(response, 200) @@ -288,7 +296,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() # Assign unconstrained permission obj_perm = ObjectPermission( @@ -307,12 +315,12 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertEqual(initial_count + 1, self.model.objects.count()) - self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + self.assertEqual(initial_count + 1, self._get_queryset().count()) + self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_create_object_with_constrained_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() # Assign constrained permission obj_perm = ObjectPermission( @@ -332,7 +340,7 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 200) - self.assertEqual(initial_count, self.model.objects.count()) # Check that no object was created + self.assertEqual(initial_count, self._get_queryset().count()) # Check that no object was created # Update the ObjectPermission to allow creation obj_perm.constraints = {'pk__gt': 0} @@ -344,8 +352,8 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertEqual(initial_count + 1, self.model.objects.count()) - self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data) + self.assertEqual(initial_count + 1, self._get_queryset().count()) + self.assertInstanceEqual(self._get_queryset().order_by('pk').last(), self.form_data) class EditObjectViewTestCase(ModelViewTestCase): """ @@ -356,7 +364,7 @@ class ViewTestCases: form_data = {} def test_edit_object_without_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Try GET without permission with disable_warnings('django.request'): @@ -372,7 +380,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Assign model-level permission obj_perm = ObjectPermission( @@ -391,11 +399,11 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data) + self.assertInstanceEqual(self._get_queryset().get(pk=instance.pk), self.form_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_edit_object_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Assign constrained permission obj_perm = ObjectPermission( @@ -418,7 +426,7 @@ class ViewTestCases: 'data': post_data(self.form_data), } self.assertHttpStatus(self.client.post(**request), 302) - self.assertInstanceEqual(self.model.objects.get(pk=instance1.pk), self.form_data) + self.assertInstanceEqual(self._get_queryset().get(pk=instance1.pk), self.form_data) # Try to edit a non-permitted object request = { @@ -432,7 +440,7 @@ class ViewTestCases: Delete a single instance. """ def test_delete_object_without_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Try GET without permission with disable_warnings('django.request'): @@ -448,7 +456,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_permission(self): - instance = self.model.objects.first() + instance = self._get_queryset().first() # Assign model-level permission obj_perm = ObjectPermission( @@ -468,11 +476,11 @@ class ViewTestCases: } self.assertHttpStatus(self.client.post(**request), 302) with self.assertRaises(ObjectDoesNotExist): - self.model.objects.get(pk=instance.pk) + self._get_queryset().get(pk=instance.pk) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_delete_object_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Assign object-level permission obj_perm = ObjectPermission( @@ -496,7 +504,7 @@ class ViewTestCases: } self.assertHttpStatus(self.client.post(**request), 302) with self.assertRaises(ObjectDoesNotExist): - self.model.objects.get(pk=instance1.pk) + self._get_queryset().get(pk=instance1.pk) # Try to delete a non-permitted object request = { @@ -504,7 +512,7 @@ class ViewTestCases: 'data': post_data({'confirm': True}), } self.assertHttpStatus(self.client.post(**request), 404) - self.assertTrue(self.model.objects.filter(pk=instance2.pk).exists()) + self.assertTrue(self._get_queryset().filter(pk=instance2.pk).exists()) class ListObjectsViewTestCase(ModelViewTestCase): """ @@ -546,7 +554,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_with_constrained_permission(self): - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self._get_queryset().all()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -591,7 +599,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_create_objects_with_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.bulk_create_data), @@ -608,13 +616,13 @@ class ViewTestCases: # Bulk create objects response = self.client.post(**request) self.assertHttpStatus(response, 302) - self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) - for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: + self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) + for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: self.assertInstanceEqual(instance, self.bulk_create_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_create_objects_with_constrained_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() request = { 'path': self._get_url('add'), 'data': post_data(self.bulk_create_data), @@ -631,7 +639,7 @@ class ViewTestCases: # Attempt to make the request with unmet constraints self.assertHttpStatus(self.client.post(**request), 200) - self.assertEqual(self.model.objects.count(), initial_count) + self.assertEqual(self._get_queryset().count(), initial_count) # Update the ObjectPermission to allow creation obj_perm.constraints = {'pk__gt': 0} # Dummy constraint to allow all @@ -639,8 +647,8 @@ class ViewTestCases: response = self.client.post(**request) self.assertHttpStatus(response, 302) - self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) - for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: + self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) + for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: self.assertInstanceEqual(instance, self.bulk_create_data) class BulkImportObjectsViewTestCase(ModelViewTestCase): @@ -670,7 +678,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() data = { 'csv': self._get_csv_data(), } @@ -688,11 +696,11 @@ class ViewTestCases: # Test POST with permission self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_import_objects_with_constrained_permission(self): - initial_count = self.model.objects.count() + initial_count = self._get_queryset().count() data = { 'csv': self._get_csv_data(), } @@ -708,7 +716,7 @@ class ViewTestCases: # Attempt to import non-permitted objects self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - self.assertEqual(self.model.objects.count(), initial_count) + self.assertEqual(self._get_queryset().count(), initial_count) # Update permission constraints obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all @@ -716,7 +724,7 @@ class ViewTestCases: # Import permitted objects self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) - self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + self.assertEqual(self._get_queryset().count(), initial_count + len(self.csv_data) - 1) class BulkEditObjectsViewTestCase(ModelViewTestCase): """ @@ -728,7 +736,7 @@ class ViewTestCases: bulk_edit_data = {} def test_bulk_edit_objects_without_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True)[:3] + pk_list = self._get_queryset().values_list('pk', flat=True)[:3] data = { 'pk': pk_list, '_apply': True, # Form button @@ -744,7 +752,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True)[:3] + pk_list = self._get_queryset().values_list('pk', flat=True)[:3] data = { 'pk': pk_list, '_apply': True, # Form button @@ -763,13 +771,12 @@ class ViewTestCases: # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) - for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_constrained_permission(self): - initial_instances = self.model.objects.all()[:3] - pk_list = list(self.model.objects.values_list('pk', flat=True)[:3]) + pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3]) data = { 'pk': pk_list, '_apply': True, # Form button @@ -781,7 +788,7 @@ class ViewTestCases: # Dynamically determine a constraint that will *not* be matched by the updated objects. attr_name = list(self.bulk_edit_data.keys())[0] field = self.model._meta.get_field(attr_name) - value = field.value_from_object(self.model.objects.first()) + value = field.value_from_object(self._get_queryset().first()) # Assign constrained permission obj_perm = ObjectPermission( @@ -802,7 +809,7 @@ class ViewTestCases: # Bulk edit permitted objects self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302) - for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + for i, instance in enumerate(self._get_queryset().filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) class BulkDeleteObjectsViewTestCase(ModelViewTestCase): @@ -811,7 +818,7 @@ class ViewTestCases: """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_without_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True)[:3] + pk_list = self._get_queryset().values_list('pk', flat=True)[:3] data = { 'pk': pk_list, 'confirm': True, @@ -828,7 +835,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_permission(self): - pk_list = self.model.objects.values_list('pk', flat=True) + pk_list = self._get_queryset().values_list('pk', flat=True) data = { 'pk': pk_list, 'confirm': True, @@ -845,12 +852,12 @@ class ViewTestCases: # Try POST with model-level permission self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - self.assertEqual(self.model.objects.count(), 0) + self.assertEqual(self._get_queryset().count(), 0) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects_with_constrained_permission(self): - initial_count = self.model.objects.count() - pk_list = self.model.objects.values_list('pk', flat=True) + initial_count = self._get_queryset().count() + pk_list = self._get_queryset().values_list('pk', flat=True) data = { 'pk': pk_list, 'confirm': True, @@ -868,7 +875,7 @@ class ViewTestCases: # Attempt to bulk delete non-permitted objects self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - self.assertEqual(self.model.objects.count(), initial_count) + self.assertEqual(self._get_queryset().count(), initial_count) # Update permission constraints obj_perm.constraints = {'pk__gt': 0} # Dummy permission to allow all @@ -876,7 +883,7 @@ class ViewTestCases: # Bulk delete permitted objects self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302) - self.assertEqual(self.model.objects.count(), 0) + self.assertEqual(self._get_queryset().count(), 0) class PrimaryObjectViewTestCase( GetObjectViewTestCase, From 86d1370512993d0b252b799905ff00897a4f0407 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 Jun 2020 16:26:22 -0400 Subject: [PATCH 097/137] Apply restrict_form_fields() to import views --- netbox/utilities/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a929b3af2..6596660ce 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -629,6 +629,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Initialize model form data = form.cleaned_data['data'] model_form = self.model_form(data) + restrict_form_fields(model_form, request.user) # Assign default values for any fields which were not specified. We have to do this manually because passing # 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not @@ -782,6 +783,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): headers, records = form.cleaned_data['csv'] for row, data in enumerate(records, start=1): obj_form = self.model_form(data, headers=headers) + restrict_form_fields(obj_form, request.user) if obj_form.is_valid(): obj = self._save_obj(obj_form, request) From 0dbe248df8ff43eb0ea573bd0490c3c6bd475b13 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 10:02:00 -0400 Subject: [PATCH 098/137] Call restrict() when retrieving related Graphs --- netbox/circuits/api/views.py | 4 ++-- netbox/circuits/tests/test_api.py | 2 +- netbox/dcim/api/views.py | 12 ++++++------ netbox/dcim/tests/test_api.py | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 363392a4d..4aad07011 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -28,8 +28,8 @@ class ProviderViewSet(CustomFieldModelViewSet): """ A convenience method for rendering graphs for a particular provider. """ - provider = get_object_or_404(Provider, pk=pk) - queryset = Graph.objects.filter(type__model='provider') + provider = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='provider') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider}) return Response(serializer.data) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 4e062cc1a..f887db29e 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -49,7 +49,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): """ Test retrieval of Graphs assigned to Providers. """ - provider = self.model.objects.first() + provider = self.model.objects.unrestricted().first() ct = ContentType.objects.get(app_label='circuits', model='provider') graphs = ( Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 324edcb49..eb48d40d9 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -103,8 +103,8 @@ class SiteViewSet(CustomFieldModelViewSet): """ A convenience method for rendering graphs for a particular site. """ - site = get_object_or_404(Site, pk=pk) - queryset = Graph.objects.filter(type__model='site') + site = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='site') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site}) return Response(serializer.data) @@ -347,8 +347,8 @@ class DeviceViewSet(CustomFieldModelViewSet): """ A convenience method for rendering graphs for a particular Device. """ - device = get_object_or_404(Device, pk=pk) - queryset = Graph.objects.filter(type__model='device') + device = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='device') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device}) return Response(serializer.data) @@ -496,8 +496,8 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): """ A convenience method for rendering graphs for a particular interface. """ - interface = get_object_or_404(Interface, pk=pk) - queryset = Graph.objects.filter(type__model='interface') + interface = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='interface') serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) return Response(serializer.data) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 052a77e53..b630741e9 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -107,7 +107,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): Graph.objects.bulk_create(graphs) self.add_permissions('dcim.view_site') - url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk}) + url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.unrestricted().first().pk}) response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 3) @@ -878,7 +878,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): Graph.objects.bulk_create(graphs) self.add_permissions('dcim.view_device') - url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk}) + url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.unrestricted().first().pk}) response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 3) @@ -1245,7 +1245,7 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase): Graph.objects.bulk_create(graphs) self.add_permissions('dcim.view_interface') - url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk}) + url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.unrestricted().first().pk}) response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 3) From a6b03b88847f2ce1951b4a7b3bfd2c499271867e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 10:38:32 -0400 Subject: [PATCH 099/137] Update WritableNestedSerializer to call unrestricted() on RestrictedQuerySets --- netbox/utilities/api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 2e2d7f7f5..067af9e5a 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -254,8 +254,11 @@ class WritableNestedSerializer(ModelSerializer): # Dictionary of related object attributes if isinstance(data, dict): params = dict_to_filter_params(data) + queryset = self.Meta.model.objects + if hasattr(queryset, 'restrict'): + queryset = queryset.unrestricted() try: - return self.Meta.model.objects.get(**params) + return queryset.get(**params) except ObjectDoesNotExist: raise ValidationError( "Related object not found using the provided attributes: {}".format(params) @@ -281,8 +284,11 @@ class WritableNestedSerializer(ModelSerializer): ) # Look up object by PK + queryset = self.Meta.model.objects + if hasattr(queryset, 'restrict'): + queryset = queryset.unrestricted() try: - return self.Meta.model.objects.get(pk=int(data)) + return queryset.get(pk=int(data)) except ObjectDoesNotExist: raise ValidationError( "Related object not found using the provided numeric ID: {}".format(pk) From 6ab4640cdcbcbdc3cc19bf4841f46a989d96e79b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 10:39:06 -0400 Subject: [PATCH 100/137] Update API tests to work with RestrictedQuerySet --- netbox/utilities/testing/api.py | 46 +++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index ce4f1d1e5..fdaef4b4b 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -52,7 +52,7 @@ class APIViewTestCases: """ GET a single object as an unauthenticated user. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) response = self.client.get(url, **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) @@ -61,7 +61,7 @@ class APIViewTestCases: """ GET a single object as an authenticated user without the required permission. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) # Try GET without permission with disable_warnings('django.request'): @@ -72,9 +72,9 @@ class APIViewTestCases: """ GET a single object as an authenticated user with permission to view the object. """ - self.assertGreaterEqual(self.model.objects.count(), 2, + self.assertGreaterEqual(self.model.objects.unrestricted().count(), 2, f"Test requires the creation of at least two {self.model} instances") - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self.model.objects.unrestricted()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -104,7 +104,7 @@ class APIViewTestCases: url = self._get_list_url() response = self.client.get(url, **self.header) - self.assertEqual(len(response.data['results']), self.model.objects.count()) + self.assertEqual(len(response.data['results']), self.model.objects.unrestricted().count()) self.assertHttpStatus(response, status.HTTP_200_OK) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @@ -115,7 +115,7 @@ class APIViewTestCases: url = f'{self._get_list_url()}?brief=1' response = self.client.get(url, **self.header) - self.assertEqual(len(response.data['results']), self.model.objects.count()) + self.assertEqual(len(response.data['results']), self.model.objects.unrestricted().count()) self.assertEqual(sorted(response.data['results'][0]), self.brief_fields) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) @@ -134,9 +134,9 @@ class APIViewTestCases: """ GET a list of objects as an authenticated user with permission to view the objects. """ - self.assertGreaterEqual(self.model.objects.count(), 3, + self.assertGreaterEqual(self.model.objects.unrestricted().count(), 3, f"Test requires the creation of at least three {self.model} instances") - instance1, instance2 = self.model.objects.all()[:2] + instance1, instance2 = self.model.objects.unrestricted()[:2] # Add object-level permission obj_perm = ObjectPermission( @@ -178,11 +178,15 @@ class APIViewTestCases: obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - initial_count = self.model.objects.count() + initial_count = self.model.objects.unrestricted().count() response = self.client.post(self._get_list_url(), self.create_data[0], format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(self.model.objects.count(), initial_count + 1) - self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True) + self.assertEqual(self.model.objects.unrestricted().count(), initial_count + 1) + self.assertInstanceEqual( + self.model.objects.unrestricted().get(pk=response.data['id']), + self.create_data[0], + api=True + ) def test_bulk_create_objects(self): """ @@ -196,13 +200,17 @@ class APIViewTestCases: obj_perm.users.add(self.user) obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) - initial_count = self.model.objects.count() + initial_count = self.model.objects.unrestricted().count() response = self.client.post(self._get_list_url(), self.create_data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertEqual(len(response.data), len(self.create_data)) - self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data)) + self.assertEqual(self.model.objects.unrestricted().count(), initial_count + len(self.create_data)) for i, obj in enumerate(response.data): - self.assertInstanceEqual(self.model.objects.get(pk=obj['id']), self.create_data[i], api=True) + self.assertInstanceEqual( + self.model.objects.unrestricted().get(pk=obj['id']), + self.create_data[i], + api=True + ) class UpdateObjectViewTestCase(APITestCase): update_data = {} @@ -211,7 +219,7 @@ class APIViewTestCases: """ PATCH a single object without permission. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) update_data = self.update_data or getattr(self, 'create_data')[0] # Try PATCH without permission @@ -223,7 +231,7 @@ class APIViewTestCases: """ PATCH a single object identified by its numeric ID. """ - instance = self.model.objects.first() + instance = self.model.objects.unrestricted().first() url = self._get_detail_url(instance) update_data = self.update_data or getattr(self, 'create_data')[0] @@ -246,7 +254,7 @@ class APIViewTestCases: """ DELETE a single object without permission. """ - url = self._get_detail_url(self.model.objects.first()) + url = self._get_detail_url(self.model.objects.unrestricted().first()) # Try DELETE without permission with disable_warnings('django.request'): @@ -257,7 +265,7 @@ class APIViewTestCases: """ DELETE a single object identified by its numeric ID. """ - instance = self.model.objects.first() + instance = self.model.objects.unrestricted().first() url = self._get_detail_url(instance) # Add object-level permission @@ -270,7 +278,7 @@ class APIViewTestCases: response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertFalse(self.model.objects.filter(pk=instance.pk).exists()) + self.assertFalse(self.model.objects.unrestricted().filter(pk=instance.pk).exists()) class APIViewTestCase( GetObjectViewTestCase, From ce55d0c791e160e61103340444ea67fa9d68732e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 10:57:09 -0400 Subject: [PATCH 101/137] Tweak querysets to work with restriction --- netbox/circuits/api/views.py | 7 +++++-- netbox/circuits/models.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 4aad07011..1575a181b 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,4 +1,4 @@ -from django.db.models import Count +from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response @@ -52,7 +52,10 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - 'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device' + Prefetch('terminations', queryset=CircuitTermination.objects.unrestricted().prefetch_related( + 'site', 'connected_endpoint__device' + )), + 'type', 'tenant', 'provider', ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index dcf1c5118..72d1c0974 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -239,7 +239,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): return self.STATUS_CLASS_MAP.get(self.status) def _get_termination(self, side): - for ct in self.terminations.all(): + for ct in self.terminations.unrestricted(): if ct.term_side == side: return ct return None From 5732466e563f10061fd2a94e36907710de3ef29e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 11:07:11 -0400 Subject: [PATCH 102/137] Signal receiver should call unrestricted() --- netbox/circuits/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 86db21400..071b7af20 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -10,7 +10,7 @@ def update_circuit(instance, **kwargs): """ When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. """ - circuits = Circuit.objects.filter(pk=instance.circuit_id) + circuits = Circuit.objects.unrestricted().filter(pk=instance.circuit_id) time = timezone.now() for circuit in circuits: circuit.last_updated = time From 10e6b6ca66a7e10eb5df28b1be8e56f6ba8a605d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 11:27:23 -0400 Subject: [PATCH 103/137] Fix RestrictedQuerySet evaluation in tests --- netbox/ipam/tests/test_api.py | 2 +- netbox/ipam/tests/test_models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 70ae738b5..d7b6b3a7b 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -416,7 +416,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): """ Attempt and fail to delete a VLAN with a Prefix assigned to it. """ - vlan = VLAN.objects.first() + vlan = VLAN.objects.unrestricted().first() Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vlan=vlan) self.add_permissions('ipam.delete_vlan') diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 6091aa70e..51d1d9684 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -43,7 +43,7 @@ class TestPrefix(TestCase): Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')), Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')), )) - duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates()] + duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates().unrestricted()] self.assertSetEqual(set(duplicate_prefix_pks), {prefixes[1].pk, prefixes[2].pk}) @@ -227,7 +227,7 @@ class TestIPAddress(TestCase): IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')), IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')), )) - duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates()] + duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates().unrestricted()] self.assertSetEqual(set(duplicate_ip_pks), {ips[1].pk, ips[2].pk}) From eb45ad600e06252243808050bad54f0219991c9c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 11:35:13 -0400 Subject: [PATCH 104/137] Fix evaluation of RestrictedQuerySets --- netbox/ipam/api/views.py | 7 +++++-- netbox/ipam/models.py | 10 ++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0f84ee772..2de99dcc1 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.db.models import Count +from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_yasg.utils import swagger_auto_schema @@ -270,6 +270,9 @@ class VLANViewSet(CustomFieldModelViewSet): # class ServiceViewSet(ModelViewSet): - queryset = Service.objects.prefetch_related('device').prefetch_related('tags') + queryset = Service.objects.prefetch_related( + Prefetch('ipaddresses', queryset=IPAddress.objects.unrestricted()), + 'device', 'virtual_machine', 'tags' + ) serializer_class = serializers.ServiceSerializer filterset_class = filters.ServiceFilterSet diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 4fe0b6e06..5904178cf 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -216,7 +216,9 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): }) # Ensure that the aggregate being added is not covered by an existing aggregate - covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix)) + covering_aggregates = Aggregate.objects.unrestricted().filter( + prefix__net_contains_or_equals=str(self.prefix) + ) if self.pk: covering_aggregates = covering_aggregates.exclude(pk=self.pk) if covering_aggregates: @@ -227,7 +229,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): }) # Ensure that the aggregate being added does not cover an existing aggregate - covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix)) + covered_aggregates = Aggregate.objects.unrestricted().filter(prefix__net_contained=str(self.prefix)) if self.pk: covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: @@ -722,7 +724,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): # Check for primary IP assignment that doesn't match the assigned device/VM if self.pk and type(self.assigned_object) is Interface: - device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() + device = Device.objects.unrestricted().filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if device: if self.assigned_object is None: raise ValidationError({ @@ -734,7 +736,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): f"{self.assigned_object.device} ({self.assigned_object})" }) elif self.pk and type(self.assigned_object) is VMInterface: - vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() + vm = VirtualMachine.unrestricted().objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if vm: if self.assigned_object is None: raise ValidationError({ From 6ecbf45974807563b76d40377e4b453b909933da Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 11:48:36 -0400 Subject: [PATCH 105/137] Fix evaluation of RestrictedQuerySets --- netbox/virtualization/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 4a753561a..5d74f8468 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -176,7 +176,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. if self.pk and self.site: - nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() + nonsite_devices = Device.objects.unrestricted().filter(cluster=self).exclude(site=self.site).count() if nonsite_devices: raise ValidationError({ 'site': "{} devices are assigned as hosts for this cluster but are not in site {}".format( @@ -316,7 +316,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Check for a duplicate name on a VM assigned to the same Cluster and no Tenant. This is necessary # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # of the uniqueness constraint without manual intervention. - if self.tenant is None and VirtualMachine.objects.exclude(pk=self.pk).filter( + if self.tenant is None and VirtualMachine.objects.unrestricted().exclude(pk=self.pk).filter( name=self.name, tenant__isnull=True ): raise ValidationError({ From 89ff59d048b29eba8596402cbcf3bbe0834de9b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 12:05:00 -0400 Subject: [PATCH 106/137] Add graphs endpoint to VMInterfaceViewSet --- netbox/virtualization/api/views.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index f2a689f12..51bc567d2 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,7 +1,12 @@ from django.db.models import Count +from django.shortcuts import get_object_or_404 +from rest_framework.decorators import action +from rest_framework.response import Response from dcim.models import Device +from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet +from extras.models import Graph from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters @@ -79,3 +84,13 @@ class VMInterfaceViewSet(ModelViewSet): ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filters.VMInterfaceFilterSet + + @action(detail=True) + def graphs(self, request, pk): + """ + A convenience method for rendering graphs for a particular VM interface. + """ + vminterface = get_object_or_404(self.queryset, pk=pk) + queryset = Graph.objects.restrict(request.user).filter(type__model='vminterface') + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': vminterface}) + return Response(serializer.data) From 617e20af0bb6e5b588f5153ff22b2860c1caa809 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 12:06:36 -0400 Subject: [PATCH 107/137] Standardize VMInterfaceTest --- netbox/virtualization/tests/test_api.py | 207 +++++++----------------- 1 file changed, 56 insertions(+), 151 deletions(-) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 8d525f4fe..e108100a9 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -1,7 +1,9 @@ +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices +from extras.models import Graph from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -161,7 +163,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): """ Check that config context data is included by default in the virtual machines list. """ - virtualmachine = VirtualMachine.objects.first() + virtualmachine = VirtualMachine.objects.unrestricted().first() url = '{}?id={}'.format(reverse('virtualization-api:virtualmachine-list'), virtualmachine.pk) self.add_permissions('virtualization.view_virtualmachine') @@ -184,7 +186,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): """ data = { 'name': 'Virtual Machine 1', - 'cluster': Cluster.objects.first().pk, + 'cluster': Cluster.objects.unrestricted().first().pk, } url = reverse('virtualization-api:virtualmachine-list') self.add_permissions('virtualization.add_virtualmachine') @@ -193,169 +195,72 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -# TODO: Standardize InterfaceTest (pending #4721) -class VMInterfaceTest(APITestCase): +class VMInterfaceTest(APIViewTestCases.APIViewTestCase): + model = VMInterface + brief_fields = ['id', 'name', 'url', 'virtual_machine'] - def setUp(self): - - super().setUp() + @classmethod + def setUpTestData(cls): clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype) - self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') - self.interface1 = VMInterface.objects.create( - virtual_machine=self.virtualmachine, - name='Test Interface 1' + virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') + + interfaces = ( + VMInterface(virtual_machine=virtualmachine, name='Interface 1'), + VMInterface(virtual_machine=virtualmachine, name='Interface 2'), + VMInterface(virtual_machine=virtualmachine, name='Interface 3'), ) - self.interface2 = VMInterface.objects.create( - virtual_machine=self.virtualmachine, - name='Test Interface 2' - ) - self.interface3 = VMInterface.objects.create( - virtual_machine=self.virtualmachine, - name='Test Interface 3' + VMInterface.objects.bulk_create(interfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), ) + VLAN.objects.bulk_create(vlans) - self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) - self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2) - self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3) - - def test_get_interface(self): - url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('virtualization.view_vminterface') - - response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.interface1.name) - - def test_list_interfaces(self): - url = reverse('virtualization-api:vminterface-list') - self.add_permissions('virtualization.view_vminterface') - - response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) - - def test_list_interfaces_brief(self): - url = reverse('virtualization-api:vminterface-list') - self.add_permissions('virtualization.view_vminterface') - - response = self.client.get('{}?brief=1'.format(url), **self.header) - self.assertEqual( - sorted(response.data['results'][0]), - ['id', 'name', 'url', 'virtual_machine'] - ) - - def test_create_interface(self): - data = { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', - } - url = reverse('virtualization-api:vminterface-list') - self.add_permissions('virtualization.add_vminterface') - - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VMInterface.objects.count(), 4) - interface4 = VMInterface.objects.get(pk=response.data['id']) - self.assertEqual(interface4.virtual_machine_id, data['virtual_machine']) - self.assertEqual(interface4.name, data['name']) - - def test_create_interface_with_802_1q(self): - data = { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan3.id, - 'tagged_vlans': [self.vlan1.id, self.vlan2.id], - } - url = reverse('virtualization-api:vminterface-list') - self.add_permissions('virtualization.add_vminterface') - - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VMInterface.objects.count(), 4) - self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine']) - self.assertEqual(response.data['name'], data['name']) - self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan']) - self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans']) - - def test_create_interface_bulk(self): - data = [ + cls.create_data = [ { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', + 'virtual_machine': virtualmachine.pk, + 'name': 'Interface 4', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tagged_vlans': [vlans[0].pk, vlans[1].pk], + 'untagged_vlan': vlans[2].pk, }, { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 5', + 'virtual_machine': virtualmachine.pk, + 'name': 'Interface 5', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tagged_vlans': [vlans[0].pk, vlans[1].pk], + 'untagged_vlan': vlans[2].pk, }, { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 6', + 'virtual_machine': virtualmachine.pk, + 'name': 'Interface 6', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'tagged_vlans': [vlans[0].pk, vlans[1].pk], + 'untagged_vlan': vlans[2].pk, }, ] - url = reverse('virtualization-api:vminterface-list') - self.add_permissions('virtualization.add_vminterface') - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VMInterface.objects.count(), 6) - self.assertEqual(response.data[0]['name'], data[0]['name']) - self.assertEqual(response.data[1]['name'], data[1]['name']) - self.assertEqual(response.data[2]['name'], data[2]['name']) + def test_get_interface_graphs(self): + """ + Test retrieval of Graphs assigned to VM interfaces. + """ + ct = ContentType.objects.get_for_model(VMInterface) + graphs = ( + Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'), + Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'), + Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'), + ) + Graph.objects.bulk_create(graphs) - def test_create_interface_802_1q_bulk(self): - data = [ - { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 4', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan2.id, - 'tagged_vlans': [self.vlan1.id], - }, - { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 5', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan2.id, - 'tagged_vlans': [self.vlan1.id], - }, - { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface 6', - 'mode': InterfaceModeChoices.MODE_TAGGED, - 'untagged_vlan': self.vlan2.id, - 'tagged_vlans': [self.vlan1.id], - }, - ] - url = reverse('virtualization-api:vminterface-list') - self.add_permissions('virtualization.add_vminterface') + self.add_permissions('virtualization.view_vminterface') + url = reverse('virtualization-api:vminterface-graphs', kwargs={ + 'pk': VMInterface.objects.unrestricted().first().pk + }) + response = self.client.get(url, **self.header) - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VMInterface.objects.count(), 6) - for i in range(0, 3): - self.assertEqual(response.data[i]['name'], data[i]['name']) - self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans']) - self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan']) - - def test_update_interface(self): - data = { - 'virtual_machine': self.virtualmachine.pk, - 'name': 'Test Interface X', - } - url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('virtualization.change_vminterface') - - response = self.client.put(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(VMInterface.objects.count(), 3) - interface1 = VMInterface.objects.get(pk=response.data['id']) - self.assertEqual(interface1.name, data['name']) - - def test_delete_interface(self): - url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk}) - self.add_permissions('virtualization.delete_vminterface') - - response = self.client.delete(url, **self.header) - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(VMInterface.objects.count(), 2) + self.assertEqual(len(response.data), 3) + self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') From 9ea4f82eaa9e50e33e5357d6015a4a99278f0c8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 12:18:59 -0400 Subject: [PATCH 108/137] Prefetch tagged VLANs for VMInterfaces --- netbox/virtualization/api/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 51bc567d2..e3c3224e4 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,4 +1,4 @@ -from django.db.models import Count +from django.db.models import Count, Prefetch from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response @@ -7,6 +7,7 @@ from dcim.models import Device from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph +from ipam.models import VLAN from utilities.api import ModelViewSet from utilities.utils import get_subquery from virtualization import filters @@ -80,6 +81,7 @@ class VMInterfaceViewSet(ModelViewSet): queryset = VMInterface.objects.filter( virtual_machine__isnull=False ).prefetch_related( + Prefetch('tagged_vlans', queryset=VLAN.objects.unrestricted()), 'virtual_machine', 'tags' ) serializer_class = serializers.VMInterfaceSerializer From a47a100cb7a761e0d1064ff5f0f9f8e877e4b24b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 13:30:41 -0400 Subject: [PATCH 109/137] Fix unrestricted evaluations of RestrictedQuerySet --- netbox/dcim/api/views.py | 2 +- netbox/dcim/models/__init__.py | 49 ++++++++++++++--------- netbox/dcim/tests/test_api.py | 72 +++++++++++++++++----------------- 3 files changed, 66 insertions(+), 57 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index eb48d40d9..2a3619a2f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -226,7 +226,7 @@ class ManufacturerViewSet(ModelViewSet): # class DeviceTypeViewSet(CustomFieldModelViewSet): - queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate( + queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( device_count=Count('instances') ) serializer_class = serializers.DeviceTypeSerializer diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index f930ae02d..13e80c60a 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -580,7 +580,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel): if self.pk: # Validate that Rack is tall enough to house the installed Devices - top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first() + top_device = Device.objects.unrestricted().filter( + rack=self + ).exclude( + position__isnull=True + ).order_by('-position').first() if top_device: min_height = top_device.position + top_device.device_type.u_height - 1 if self.u_height < min_height: @@ -601,13 +605,13 @@ class Rack(ChangeLoggedModel, CustomFieldModel): # Record the original site assignment for this rack. _site_id = None if self.pk: - _site_id = Rack.objects.get(pk=self.pk).site_id + _site_id = Rack.objects.unrestricted().get(pk=self.pk).site_id super().save(*args, **kwargs) # Update racked devices if the assigned Site has been changed. if _site_id is not None and self.site_id != _site_id: - devices = Device.objects.filter(rack=self) + devices = Device.objects.unrestricted().filter(rack=self) for device in devices: device.site = self.site device.save() @@ -1125,7 +1129,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # room to expand within their racks. This validation will impose a very high performance penalty when there are # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. if self.pk and self.u_height > self._original_u_height: - for d in Device.objects.filter(device_type=self, position__isnull=False): + for d in Device.objects.unrestricted().filter(device_type=self, position__isnull=False): face_required = None if self.is_full_depth else d.face u_available = d.rack.get_available_units( u_height=self.u_height, @@ -1140,7 +1144,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position. elif self.pk and self._original_u_height > 0 and self.u_height == 0: - racked_instance_count = Device.objects.filter(device_type=self, position__isnull=False).count() + racked_instance_count = Device.objects.unrestricted().filter( + device_type=self, + position__isnull=False + ).count() if racked_instance_count: url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}" raise ValidationError({ @@ -1493,7 +1500,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # of the uniqueness constraint without manual intervention. if self.name and self.tenant is None: - if Device.objects.exclude(pk=self.pk).filter(name=self.name, site=self.site, tenant__isnull=True): + if Device.objects.unrestricted().exclude(pk=self.pk).filter( + name=self.name, + site=self.site, + tenant__isnull=True + ): raise ValidationError({ 'name': 'A device with this name already exists.' }) @@ -1623,32 +1634,32 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleport_templates.unrestricted()] ) ConsoleServerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()] + [x.instantiate(self) for x in self.device_type.consoleserverport_templates.unrestricted()] ) PowerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.powerport_templates.all()] + [x.instantiate(self) for x in self.device_type.powerport_templates.unrestricted()] ) PowerOutlet.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()] + [x.instantiate(self) for x in self.device_type.poweroutlet_templates.unrestricted()] ) Interface.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.interface_templates.all()] + [x.instantiate(self) for x in self.device_type.interface_templates.unrestricted()] ) RearPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.rearport_templates.all()] + [x.instantiate(self) for x in self.device_type.rearport_templates.unrestricted()] ) FrontPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.frontport_templates.all()] + [x.instantiate(self) for x in self.device_type.frontport_templates.unrestricted()] ) DeviceBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.device_bay_templates.all()] + [x.instantiate(self) for x in self.device_type.device_bay_templates.unrestricted()] ) # Update Site and Rack assignment for any child Devices - devices = Device.objects.filter(parent_bay__device=self) + devices = Device.objects.unrestricted().filter(parent_bay__device=self) for device in devices: device.site = self.site device.rack = self.rack @@ -1739,7 +1750,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ Return the set of child Devices installed in DeviceBays within this Device. """ - return Device.objects.filter(parent_bay__device=self.pk) + return Device.objects.unrestricted().filter(parent_bay__device=self.pk) def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) @@ -1796,7 +1807,7 @@ class VirtualChassis(ChangeLoggedModel): def delete(self, *args, **kwargs): # Check for LAG interfaces split across member chassis - interfaces = Interface.objects.filter( + interfaces = Interface.objects.unrestricted().filter( device__in=self.members.all(), lag__isnull=False ).exclude( @@ -2169,7 +2180,7 @@ class Cable(ChangeLoggedModel): if not hasattr(self, 'termination_a_type'): raise ValidationError('Termination A type has not been specified') try: - self.termination_a_type.model_class().objects.get(pk=self.termination_a_id) + self.termination_a_type.model_class().objects.unrestricted().get(pk=self.termination_a_id) except ObjectDoesNotExist: raise ValidationError({ 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type) @@ -2179,7 +2190,7 @@ class Cable(ChangeLoggedModel): if not hasattr(self, 'termination_b_type'): raise ValidationError('Termination B type has not been specified') try: - self.termination_b_type.model_class().objects.get(pk=self.termination_b_id) + self.termination_b_type.model_class().objects.unrestricted().get(pk=self.termination_b_id) except ObjectDoesNotExist: raise ValidationError({ 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index b630741e9..2db6569a9 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -107,7 +107,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase): Graph.objects.bulk_create(graphs) self.add_permissions('dcim.view_site') - url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.unrestricted().first().pk}) + url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.unrestricted().unrestricted().first().pk}) response = self.client.get(url, **self.header) self.assertEqual(len(response.data), 3) @@ -246,7 +246,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): """ GET a single rack elevation. """ - rack = Rack.objects.first() + rack = Rack.objects.unrestricted().first() self.add_permissions('dcim.view_rack') url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}) @@ -266,7 +266,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): """ GET a single rack elevation in SVG format. """ - rack = Rack.objects.first() + rack = Rack.objects.unrestricted().first() self.add_permissions('dcim.view_rack') url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})) @@ -281,9 +281,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - user = User.objects.create(username='user1', is_active=True) - site = Site.objects.create(name='Test Site 1', slug='test-site-1') cls.racks = ( @@ -908,7 +906,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): """ Check that creating a device with a duplicate name within a site fails. """ - device = Device.objects.first() + device = Device.objects.unrestricted().first() data = { 'device_type': device.device_type.pk, 'device_role': device.device_role.pk, @@ -1640,11 +1638,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) - consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + consoleport1 = ConsolePort.objects.unrestricted().get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.unrestricted().get(pk=consoleserverport1.pk) self.assertEqual(cable.termination_a, consoleport1) self.assertEqual(cable.termination_b, consoleserverport1) @@ -1705,12 +1703,12 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - cable = Cable.objects.get(pk=response.data['id']) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) self.assertEqual(cable.termination_a.cable, cable) self.assertEqual(cable.termination_b.cable, cable) - consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) - consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) + consoleport1 = ConsolePort.objects.unrestricted().get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.unrestricted().get(pk=consoleserverport1.pk) self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) @@ -1735,11 +1733,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - powerport1 = PowerPort.objects.get(pk=powerport1.pk) - poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + powerport1 = PowerPort.objects.unrestricted().get(pk=powerport1.pk) + poweroutlet1 = PowerOutlet.objects.unrestricted().get(pk=poweroutlet1.pk) self.assertEqual(cable.termination_a, powerport1) self.assertEqual(cable.termination_b, poweroutlet1) @@ -1771,11 +1769,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - interface1 = Interface.objects.get(pk=interface1.pk) - interface2 = Interface.objects.get(pk=interface2.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + interface2 = Interface.objects.unrestricted().get(pk=interface2.pk) self.assertEqual(cable.termination_a, interface1) self.assertEqual(cable.termination_b, interface2) @@ -1836,12 +1834,12 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - cable = Cable.objects.get(pk=response.data['id']) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) self.assertEqual(cable.termination_a.cable, cable) self.assertEqual(cable.termination_b.cable, cable) - interface1 = Interface.objects.get(pk=interface1.pk) - interface2 = Interface.objects.get(pk=interface2.pk) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + interface2 = Interface.objects.unrestricted().get(pk=interface2.pk) self.assertEqual(interface1.connected_endpoint, interface2) self.assertEqual(interface2.connected_endpoint, interface1) @@ -1875,11 +1873,11 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Cable.objects.count(), 1) + self.assertEqual(Cable.objects.unrestricted().count(), 1) - cable = Cable.objects.get(pk=response.data['id']) - interface1 = Interface.objects.get(pk=interface1.pk) - circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.unrestricted().get(pk=circuittermination1.pk) self.assertEqual(cable.termination_a, interface1) self.assertEqual(cable.termination_b, circuittermination1) @@ -1949,12 +1947,12 @@ class ConnectionTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - cable = Cable.objects.get(pk=response.data['id']) + cable = Cable.objects.unrestricted().get(pk=response.data['id']) self.assertEqual(cable.termination_a.cable, cable) self.assertEqual(cable.termination_b.cable, cable) - interface1 = Interface.objects.get(pk=interface1.pk) - circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) + interface1 = Interface.objects.unrestricted().get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.unrestricted().get(pk=circuittermination1.pk) self.assertEqual(interface1.connected_endpoint, circuittermination1) self.assertEqual(circuittermination1.connected_endpoint, interface1) @@ -2045,12 +2043,12 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'), ) VirtualChassis.objects.bulk_create(virtual_chassis) - Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2) - Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3) - Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2) - Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3) - Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2) - Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3) + Device.objects.unrestricted().filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2) + Device.objects.unrestricted().filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3) + Device.objects.unrestricted().filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2) + Device.objects.unrestricted().filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3) + Device.objects.unrestricted().filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2) + Device.objects.unrestricted().filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3) cls.update_data = { 'name': 'Virtual Chassis X', From 5ed613691535a30de26554f7c0ca1d5aa3f38037 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 13:57:08 -0400 Subject: [PATCH 110/137] Introduce ComponentTraceMixin to minimize boilerplate --- netbox/dcim/tests/test_api.py | 259 +++++++--------------------------- 1 file changed, 49 insertions(+), 210 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 2db6569a9..44c8f6223 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -28,6 +28,41 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class ComponentTraceMixin(APITestCase): + peer_termination_type = None + + def test_trace(self): + """ + Test tracing a device component's attached cable. + """ + obj = self.model.objects.unrestricted().first() + peer_device = Device.objects.create( + site=Site.objects.unrestricted().first(), + device_type=DeviceType.objects.unrestricted().first(), + device_role=DeviceRole.objects.unrestricted().first(), + name='Peer Device' + ) + if self.peer_termination_type is None: + raise NotImplementedError("Test case must set peer_termination_type") + peer_obj = self.peer_termination_type.objects.create( + device=peer_device, + name='Peer Termination' + ) + cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1') + cable.save() + + self.add_permissions(f'dcim.view_{self.model._meta.model_name}') + url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], obj.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], peer_obj.name) + + class RegionTest(APIViewTestCases.APIViewTestCase): model = Region brief_fields = ['id', 'name', 'site_count', 'slug', 'url'] @@ -921,9 +956,10 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -class ConsolePortTest(APIViewTestCases.APIViewTestCase): +class ConsolePortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = ConsoleServerPort @classmethod def setUpTestData(cls): @@ -955,39 +991,11 @@ class ConsolePortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_consoleport(self): - """ - Test tracing a ConsolePort cable. - """ - consoleport = ConsolePort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - consoleserverport = ConsoleServerPort.objects.create( - device=peer_device, - name='Console Server Port 1' - ) - cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_consoleport') - url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], consoleport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], consoleserverport.name) - - -class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase): +class ConsoleServerPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = ConsolePort @classmethod def setUpTestData(cls): @@ -1019,39 +1027,11 @@ class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_consoleserverport(self): - """ - Test tracing a ConsoleServerPort cable. - """ - consoleserverport = ConsoleServerPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - consoleport = ConsolePort.objects.create( - device=peer_device, - name='Console Port 1' - ) - cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_consoleserverport') - url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], consoleserverport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], consoleport.name) - - -class PowerPortTest(APIViewTestCases.APIViewTestCase): +class PowerPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = PowerOutlet @classmethod def setUpTestData(cls): @@ -1083,39 +1063,11 @@ class PowerPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_powerport(self): - """ - Test tracing a PowerPort cable. - """ - powerport = PowerPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - poweroutlet = PowerOutlet.objects.create( - device=peer_device, - name='Power Outlet 1' - ) - cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_powerport') - url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], powerport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], poweroutlet.name) - - -class PowerOutletTest(APIViewTestCases.APIViewTestCase): +class PowerOutletTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = PowerPort @classmethod def setUpTestData(cls): @@ -1147,39 +1099,11 @@ class PowerOutletTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_poweroutlet(self): - """ - Test tracing a PowerOutlet cable. - """ - poweroutlet = PowerOutlet.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - powerport = PowerPort.objects.create( - device=peer_device, - name='Power Port 1' - ) - cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_poweroutlet') - url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], poweroutlet.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], powerport.name) - - -class InterfaceTest(APIViewTestCases.APIViewTestCase): +class InterfaceTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + peer_termination_type = Interface @classmethod def setUpTestData(cls): @@ -1249,39 +1173,11 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase): self.assertEqual(len(response.data), 3) self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') - def test_trace_interface(self): - """ - Test tracing an Interface cable. - """ - interface_a = Interface.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - interface_b = Interface.objects.create( - device=peer_device, - name='Interface X' - ) - cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_interface') - url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], interface_a.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], interface_b.name) - - -class FrontPortTest(APIViewTestCases.APIViewTestCase): +class FrontPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + peer_termination_type = Interface @classmethod def setUpTestData(cls): @@ -1332,39 +1228,11 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_frontport(self): - """ - Test tracing a FrontPort cable. - """ - frontport = FrontPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - interface = Interface.objects.create( - device=peer_device, - name='Interface X' - ) - cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1') - cable.save() - self.add_permissions('dcim.view_frontport') - url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], frontport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], interface.name) - - -class RearPortTest(APIViewTestCases.APIViewTestCase): +class RearPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + peer_termination_type = Interface @classmethod def setUpTestData(cls): @@ -1399,35 +1267,6 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): }, ] - def test_trace_rearport(self): - """ - Test tracing a RearPort cable. - """ - rearport = RearPort.objects.first() - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - interface = Interface.objects.create( - device=peer_device, - name='Interface X' - ) - cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1') - cable.save() - - self.add_permissions('dcim.view_rearport') - url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk}) - response = self.client.get(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], rearport.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], interface.name) - class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay From 71812d1bd54c3710db346a6d0adbc27e9a650fd8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 14:41:43 -0400 Subject: [PATCH 111/137] Fix evaluation of RestrictedQuerySet --- netbox/dcim/api/views.py | 12 ++++++++---- netbox/dcim/models/__init__.py | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2a3619a2f..8b88baa93 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -43,7 +43,7 @@ class CableTraceMixin(object): """ Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination). """ - obj = get_object_or_404(self.queryset.model, pk=pk) + obj = get_object_or_404(self.queryset, pk=pk) # Initialize the path array path = [] @@ -156,7 +156,7 @@ class RackViewSet(CustomFieldModelViewSet): """ Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG. """ - rack = get_object_or_404(Rack, pk=pk) + rack = get_object_or_404(self.queryset, pk=pk) serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET) if not serializer.is_valid(): return Response(serializer.errors, 400) @@ -369,7 +369,7 @@ class DeviceViewSet(CustomFieldModelViewSet): """ Execute a NAPALM method on a Device """ - device = get_object_or_404(Device, pk=pk) + device = get_object_or_404(self.queryset, pk=pk) if not device.primary_ip: raise ServiceUnavailable("This device does not have a primary IP address configured.") if device.platform is None: @@ -655,7 +655,11 @@ class ConnectedDeviceViewSet(ViewSet): raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.') # Determine local interface from peer interface's connection - peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name) + peer_interface = get_object_or_404( + Interface.objects.unrestricted(), + device__name=peer_device_name, + name=peer_interface_name + ) local_interface = peer_interface._connected_interface if local_interface is None: diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 13e80c60a..1a90e01bc 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -673,7 +673,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): # Add devices to rack units list if self.pk: - queryset = Device.objects.prefetch_related( + queryset = Device.objects.unrestricted().prefetch_related( 'device_type', 'device_type__manufacturer', 'device_role' From 66703d89631e9a36d40424aa42a18dd87a7265d8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 14:42:37 -0400 Subject: [PATCH 112/137] Fix evaluation of RestrictedQuerySet --- netbox/extras/models/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 082a153e9..c692c043a 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -538,7 +538,7 @@ class ConfigContextModel(models.Model): # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs data = OrderedDict() - for context in ConfigContext.objects.get_for_object(self): + for context in ConfigContext.objects.unrestricted().get_for_object(self): data = deepmerge(data, context.data) # If the object has local config context data defined, merge it last From 36498c9dd2748ed0b51b3271dd95b85c041a561f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 14:57:29 -0400 Subject: [PATCH 113/137] Base manager for Tag should use RestrictedQuerySet --- netbox/extras/api/views.py | 2 +- netbox/extras/models/tags.py | 3 +-- netbox/extras/views.py | 14 +++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index b40cb4459..7e547dafd 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -108,7 +108,7 @@ class ExportTemplateViewSet(ModelViewSet): # class TagViewSet(ModelViewSet): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( tagged_items=Count('extras_taggeditem_items', distinct=True) ) serializer_class = serializers.TagSerializer diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index bd49954c9..9bb90f21e 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -22,8 +22,7 @@ class Tag(TagBase, ChangeLoggedModel): blank=True, ) - objects = models.Manager() - restricted = RestrictedQuerySet.as_manager() + objects = RestrictedQuerySet.as_manager() csv_headers = ['name', 'slug', 'color', 'description'] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 5f4a21448..55064b5cf 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -30,7 +30,7 @@ from .scripts import get_scripts, run_script # class TagListView(ObjectListView): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' @@ -41,7 +41,7 @@ class TagListView(ObjectListView): class TagView(ObjectView): - queryset = Tag.restricted.all() + queryset = Tag.objects.all() def get(self, request, slug): @@ -68,26 +68,26 @@ class TagView(ObjectView): class TagEditView(ObjectEditView): - queryset = Tag.restricted.all() + queryset = Tag.objects.all() model_form = forms.TagForm default_return_url = 'extras:tag_list' template_name = 'extras/tag_edit.html' class TagDeleteView(ObjectDeleteView): - queryset = Tag.restricted.all() + queryset = Tag.objects.all() default_return_url = 'extras:tag_list' class TagBulkImportView(BulkImportView): - queryset = Tag.restricted.all() + queryset = Tag.objects.all() model_form = forms.TagCSVForm table = tables.TagTable default_return_url = 'extras:tag_list' class TagBulkEditView(BulkEditView): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' @@ -98,7 +98,7 @@ class TagBulkEditView(BulkEditView): class TagBulkDeleteView(BulkDeleteView): - queryset = Tag.restricted.annotate( + queryset = Tag.objects.annotate( items=Count('extras_taggeditem_items') ).order_by( 'name' From 15f32bdd734484edffc92ccca1a96b989ba22102 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 15:14:12 -0400 Subject: [PATCH 114/137] Wrap ComponentTraceMixin in a parent class --- netbox/dcim/tests/test_api.py | 76 ++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 44c8f6223..451d9d9a9 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -28,39 +28,41 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) -class ComponentTraceMixin(APITestCase): - peer_termination_type = None +class Mixins: - def test_trace(self): - """ - Test tracing a device component's attached cable. - """ - obj = self.model.objects.unrestricted().first() - peer_device = Device.objects.create( - site=Site.objects.unrestricted().first(), - device_type=DeviceType.objects.unrestricted().first(), - device_role=DeviceRole.objects.unrestricted().first(), - name='Peer Device' - ) - if self.peer_termination_type is None: - raise NotImplementedError("Test case must set peer_termination_type") - peer_obj = self.peer_termination_type.objects.create( - device=peer_device, - name='Peer Termination' - ) - cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1') - cable.save() + class ComponentTraceMixin(APITestCase): + peer_termination_type = None - self.add_permissions(f'dcim.view_{self.model._meta.model_name}') - url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk}) - response = self.client.get(url, **self.header) + def test_trace(self): + """ + Test tracing a device component's attached cable. + """ + obj = self.model.objects.unrestricted().first() + peer_device = Device.objects.create( + site=Site.objects.unrestricted().first(), + device_type=DeviceType.objects.unrestricted().first(), + device_role=DeviceRole.objects.unrestricted().first(), + name='Peer Device' + ) + if self.peer_termination_type is None: + raise NotImplementedError("Test case must set peer_termination_type") + peer_obj = self.peer_termination_type.objects.create( + device=peer_device, + name='Peer Termination' + ) + cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1') + cable.save() - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], obj.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], peer_obj.name) + self.add_permissions(f'dcim.view_{self.model._meta.model_name}') + url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], obj.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], peer_obj.name) class RegionTest(APIViewTestCases.APIViewTestCase): @@ -956,7 +958,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) -class ConsolePortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] peer_termination_type = ConsoleServerPort @@ -992,7 +994,7 @@ class ConsolePortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): ] -class ConsoleServerPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] peer_termination_type = ConsolePort @@ -1028,7 +1030,7 @@ class ConsoleServerPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCas ] -class PowerPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] peer_termination_type = PowerOutlet @@ -1064,7 +1066,7 @@ class PowerPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): ] -class PowerOutletTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] peer_termination_type = PowerPort @@ -1100,7 +1102,7 @@ class PowerOutletTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): ] -class InterfaceTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] peer_termination_type = Interface @@ -1174,7 +1176,7 @@ class InterfaceTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1') -class FrontPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] peer_termination_type = Interface @@ -1229,7 +1231,7 @@ class FrontPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): ] -class RearPortTest(ComponentTraceMixin, APIViewTestCases.APIViewTestCase): +class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] peer_termination_type = Interface From af778f8fcafc29846cd14dde4b466ffeff86c6b7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 29 Jun 2020 15:36:02 -0400 Subject: [PATCH 115/137] TagFilter should call unrestricted() on its queryset --- netbox/utilities/filters.py | 2 +- netbox/utilities/forms.py | 2 +- netbox/utilities/tables.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index f628ca917..b13c55f40 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -102,7 +102,7 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): kwargs.setdefault('field_name', 'tags__slug') kwargs.setdefault('to_field_name', 'slug') kwargs.setdefault('conjoined', True) - kwargs.setdefault('queryset', Tag.objects.all()) + kwargs.setdefault('queryset', Tag.objects.unrestricted()) super().__init__(*args, **kwargs) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 0d15d34df..b2ce1592a 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -596,7 +596,7 @@ class TagFilterField(forms.MultipleChoiceField): def __init__(self, model, *args, **kwargs): def get_choices(): - tags = model.tags.annotate( + tags = model.tags.all().unrestricted().annotate( count=Count('extras_taggeditem_items') ).order_by('name') return [ diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 10e408b43..5e277e633 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -151,7 +151,7 @@ class TagColumn(tables.TemplateColumn): Display a list of tags assigned to the object. """ template_code = """ - {% for tag in value.all %} + {% for tag in value.all.unrestricted %} {% include 'utilities/templatetags/tag.html' %} {% empty %} From 268b4c854ecf12f76304e8db0261def1598c566f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 09:00:42 -0400 Subject: [PATCH 116/137] 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 117/137] 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 118/137] 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 119/137] 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 52a13b19601961e985d0ea94049f7232835736cc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 15:12:53 -0400 Subject: [PATCH 120/137] Closes #4793: Add description field to device component templates --- docs/release-notes/version-2.9.md | 2 + netbox/dcim/api/serializers.py | 16 +++--- netbox/dcim/forms.py | 55 +++++++++++++------ .../0111_component_template_description.py | 53 ++++++++++++++++++ .../dcim/models/device_component_templates.py | 5 ++ netbox/dcim/tables.py | 16 +++--- 6 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 netbox/dcim/migrations/0111_component_template_description.py diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 3fefd8569..1d20201e9 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -16,6 +16,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations * [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components * [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports +* [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates * [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports ### Configuration Changes @@ -40,6 +41,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * The IP address model now uses a generic foreign key to refer to the assigned interface. The `interface` field on the serializer has been replaced with `assigned_object_type` and `assigned_object_id` for write operations. If one exists, the assigned interface is available as `assigned_object`. * The serialized representation of a virtual machine interface now includes only relevant fields: `type`, `lag`, `mgmt_only`, `connected_endpoint_type`, `connected_endpoint`, and `cable` are no longer included. * dcim.VirtualChassis: Added a mandatory `name` field +* An optional `description` field has been added to all device component templates ### Other Changes diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 45a908685..18e35aabe 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -245,7 +245,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'description'] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): @@ -258,7 +258,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'description'] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -271,7 +271,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): @@ -292,7 +292,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -301,7 +301,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description'] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -310,7 +310,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = RearPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'positions'] + fields = ['id', 'device_type', 'name', 'type', 'positions', 'description'] class FrontPortTemplateSerializer(ValidatedModelSerializer): @@ -320,7 +320,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = FrontPortTemplate - fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position'] + fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] class DeviceBayTemplateSerializer(ValidatedModelSerializer): @@ -328,7 +328,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'device_type', 'name', 'label'] + fields = ['id', 'device_type', 'name', 'label', 'description'] # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index deb61729f..2244ef443 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1053,6 +1053,9 @@ class ComponentTemplateCreateForm(LabeledComponentForm): display_field='model' ) ) + description = forms.CharField( + required=False + ) class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1060,7 +1063,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', + 'device_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1086,7 +1089,7 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ('type',) + nullable_fields = ('type', 'description') class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1094,7 +1097,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', + 'device_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1118,9 +1121,12 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = ('type',) + nullable_fields = ('type', 'description') class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1128,7 +1134,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1172,9 +1178,12 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, help_text="Allocated power draw (watts)" ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = ('type', 'maximum_draw', 'allocated_draw') + nullable_fields = ('type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1182,7 +1191,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1251,9 +1260,12 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = ('type', 'power_port', 'feed_leg') + nullable_fields = ('type', 'power_port', 'feed_leg', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1272,7 +1284,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', + 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1306,9 +1318,12 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): widget=BulkEditNullBooleanSelect, label='Management only' ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = [] + nullable_fields = ('description',) class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1316,7 +1331,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1401,9 +1416,12 @@ class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = () + nullable_fields = ('description',) class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1411,7 +1429,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', + 'device_type', 'name', 'type', 'positions', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1442,9 +1460,12 @@ class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) + description = forms.CharField( + required=False + ) class Meta: - nullable_fields = () + nullable_fields = ('description',) class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1452,7 +1473,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate fields = [ - 'device_type', 'name', 'label', + 'device_type', 'name', 'label', 'description', ] widgets = { 'device_type': forms.HiddenInput(), @@ -1460,7 +1481,9 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): - pass + description = forms.CharField( + required=False + ) # TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet diff --git a/netbox/dcim/migrations/0111_component_template_description.py b/netbox/dcim/migrations/0111_component_template_description.py new file mode 100644 index 000000000..3040f586c --- /dev/null +++ b/netbox/dcim/migrations/0111_component_template_description.py @@ -0,0 +1,53 @@ +# Generated by Django 3.0.6 on 2020-06-30 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0110_virtualchassis_name'), + ] + + operations = [ + migrations.AddField( + model_name='consoleporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='frontporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='interfacetemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='powerporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='rearporttemplate', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 904352196..1c2be0e5d 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -27,6 +27,11 @@ __all__ = ( class ComponentTemplateModel(models.Model): + description = models.CharField( + max_length=200, + blank=True + ) + objects = RestrictedQuerySet.as_manager() class Meta: diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 9979bea1a..dd6b96406 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -486,7 +486,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = ConsolePortTemplate - fields = ('pk', 'name', 'label', 'type', 'actions') + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -499,7 +499,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = ConsoleServerPortTemplate - fields = ('pk', 'name', 'label', 'type', 'actions') + fields = ('pk', 'name', 'label', 'type', 'description', 'actions') empty_text = "None" @@ -512,7 +512,7 @@ class PowerPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = PowerPortTemplate - fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'actions') + fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions') empty_text = "None" @@ -525,7 +525,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = PowerOutletTemplate - fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'actions') + fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions') empty_text = "None" @@ -541,7 +541,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = InterfaceTemplate - fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'actions') + fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions') empty_text = "None" @@ -557,7 +557,7 @@ class FrontPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = FrontPortTemplate - fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'actions') + fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions') empty_text = "None" @@ -570,7 +570,7 @@ class RearPortTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = RearPortTemplate - fields = ('pk', 'name', 'label', 'type', 'positions', 'actions') + fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions') empty_text = "None" @@ -583,7 +583,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class Meta(BaseTable.Meta): model = DeviceBayTemplate - fields = ('pk', 'name', 'label', 'actions') + fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" From 88e3ac30b62cae6d8dbc9e4bc6a47f2f6104e9a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 15:22:30 -0400 Subject: [PATCH 121/137] Closes #4807: Add bulk edit ability for device bay templates --- docs/release-notes/version-2.9.md | 1 + netbox/dcim/forms.py | 20 +++++++++++--------- netbox/dcim/tests/test_views.py | 13 +++++-------- netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 8 ++++---- netbox/templates/dcim/devicetype.html | 2 +- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 1d20201e9..c0d51afc2 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -18,6 +18,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo * [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports * [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates * [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports +* [#4807](https://github.com/netbox-community/netbox/issues/4807) - Add bulk edit ability for device bay templates ### Configuration Changes diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2244ef443..374f5fa4b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1486,15 +1486,17 @@ class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): ) -# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet -# class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): -# pk = forms.ModelMultipleChoiceField( -# queryset=FrontPortTemplate.objects.all(), -# widget=forms.MultipleHiddenInput() -# ) -# -# class Meta: -# nullable_fields = () +class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBayTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ('description',) # diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 079f26fdf..b3994ea8c 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -813,14 +813,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase } -# TODO: Change base class to DeviceComponentTemplateViewTestCase -# Blocked by absence of bulk edit view for DeviceBays -class DeviceBayTemplateTestCase( - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.BulkCreateObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate @classmethod @@ -848,6 +841,10 @@ class DeviceBayTemplateTestCase( 'name_pattern': 'Device Bay Template [4-6]', } + cls.bulk_edit_data = { + 'description': 'Foo bar', + } + class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = DeviceRole diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 45b10cd0c..087f9db62 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -142,7 +142,7 @@ urlpatterns = [ # Device bay templates path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), - # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), + path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0ce35aa19..864665701 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -867,10 +867,10 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView): queryset = DeviceBayTemplate.objects.all() -# class DeviceBayTemplateBulkEditView(BulkEditView): -# queryset = DeviceBayTemplate.objects.all() -# table = tables.DeviceBayTemplateTable -# form = forms.DeviceBayTemplateBulkEditForm +class DeviceBayTemplateBulkEditView(BulkEditView): + queryset = DeviceBayTemplate.objects.all() + table = tables.DeviceBayTemplateTable + form = forms.DeviceBayTemplateBulkEditForm class DeviceBayTemplateBulkDeleteView(BulkDeleteView): diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 669456c10..7ca29a1f7 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -173,7 +173,7 @@ {% if devicetype.is_parent_device or devicebay_table.rows %}
    - {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url=None delete_url='dcim:devicebaytemplate_bulk_delete' %} + {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url='dcim:devicebaytemplate_bulk_edit' delete_url='dcim:devicebaytemplate_bulk_delete' %}
    {% endif %} From 7defa22b0b6062c908d1dfc08d7377d5056fe17d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 15:55:15 -0400 Subject: [PATCH 122/137] 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 89ea34015d1adafd34c64b439943c20c2de642e7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 16:15:17 -0400 Subject: [PATCH 123/137] Enable bulk editing of device component labels --- netbox/dcim/forms.py | 95 +++++++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 40 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 374f5fa4b..1857e5f8f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,10 +23,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, - NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, - BOOLEAN_WITH_BLANK_CHOICES, + ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, + SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -1082,6 +1081,10 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=ConsolePortTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -1089,7 +1092,7 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ('type', 'description') + nullable_fields = ('label', 'type', 'description') class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1116,6 +1119,10 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=ConsoleServerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, @@ -1126,7 +1133,7 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ('type', 'description') + nullable_fields = ('label', 'type', 'description') class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1163,6 +1170,10 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=PowerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False, @@ -1183,7 +1194,7 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ('type', 'maximum_draw', 'allocated_draw', 'description') + nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1246,6 +1257,10 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): disabled=True, widget=forms.HiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False, @@ -1265,7 +1280,7 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ('type', 'power_port', 'feed_leg', 'description') + nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1308,6 +1323,10 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) type = forms.ChoiceField( choices=add_blank_choice(InterfaceTypeChoices), required=False, @@ -1323,7 +1342,7 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ('description',) + nullable_fields = ('label', 'description') class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): @@ -1491,12 +1510,16 @@ class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=DeviceBayTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) + label = forms.CharField( + max_length=64, + required=False + ) description = forms.CharField( required=False ) class Meta: - nullable_fields = ('description',) + nullable_fields = ('label', 'description') # @@ -2295,14 +2318,14 @@ class ConsolePortCreateForm(ComponentCreateForm): class ConsolePortBulkCreateForm( - form_from_model(ConsolePort, ['type', 'description', 'tags']), + form_from_model(ConsolePort, ['label', 'type', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['type', 'description']), + form_from_model(ConsolePort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2313,9 +2336,7 @@ class ConsolePortBulkEditForm( ) class Meta: - nullable_fields = ( - 'description', - ) + nullable_fields = ('label', 'description') class ConsolePortCSVForm(CSVModelForm): @@ -2377,14 +2398,14 @@ class ConsoleServerPortCreateForm(ComponentCreateForm): class ConsoleServerPortBulkCreateForm( - form_from_model(ConsoleServerPort, ['type', 'description', 'tags']), + form_from_model(ConsoleServerPort, ['label', 'type', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class ConsoleServerPortBulkEditForm( - form_from_model(ConsoleServerPort, ['type', 'description']), + form_from_model(ConsoleServerPort, ['label', 'type', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2395,9 +2416,7 @@ class ConsoleServerPortBulkEditForm( ) class Meta: - nullable_fields = [ - 'description', - ] + nullable_fields = ('label', 'description') class ConsoleServerPortCSVForm(CSVModelForm): @@ -2469,14 +2488,14 @@ class PowerPortCreateForm(ComponentCreateForm): class PowerPortBulkCreateForm( - form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']), + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class PowerPortBulkEditForm( - form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description']), + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2487,9 +2506,7 @@ class PowerPortBulkEditForm( ) class Meta: - nullable_fields = ( - 'description', - ) + nullable_fields = ('label', 'description') class PowerPortCSVForm(CSVModelForm): @@ -2581,14 +2598,14 @@ class PowerOutletCreateForm(ComponentCreateForm): class PowerOutletBulkCreateForm( - form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']), + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'description', 'tags']), DeviceBulkAddComponentForm ): pass class PowerOutletBulkEditForm( - form_from_model(PowerOutlet, ['type', 'feed_leg', 'power_port', 'description']), + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2605,9 +2622,7 @@ class PowerOutletBulkEditForm( ) class Meta: - nullable_fields = [ - 'type', 'feed_leg', 'power_port', 'description', - ] + nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2838,14 +2853,16 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description']), + form_from_model(Interface, ['label', 'type', 'enabled', 'mtu', 'mgmt_only', 'description']), DeviceBulkAddComponentForm ): pass class InterfaceBulkEditForm( - form_from_model(Interface, ['type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode']), + form_from_model(Interface, [ + 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode' + ]), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -2884,9 +2901,9 @@ class InterfaceBulkEditForm( ) class Meta: - nullable_fields = [ - 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' - ] + nullable_fields = ( + 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -3283,7 +3300,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class DeviceBayBulkCreateForm( - form_from_model(DeviceBay, ['description', 'tags']), + form_from_model(DeviceBay, ['label', 'description', 'tags']), DeviceBulkAddComponentForm ): tags = DynamicModelMultipleChoiceField( @@ -3293,7 +3310,7 @@ class DeviceBayBulkCreateForm( class DeviceBayBulkEditForm( - form_from_model(DeviceBay, ['description']), + form_from_model(DeviceBay, ['label', 'description']), BootstrapMixin, AddRemoveTagsForm, BulkEditForm @@ -3304,9 +3321,7 @@ class DeviceBayBulkEditForm( ) class Meta: - nullable_fields = ( - 'description', - ) + nullable_fields = ('label', 'description') class DeviceBayCSVForm(CSVModelForm): From 7fab929194f1595f428df07b6ad8092441f89a71 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 16:30:54 -0400 Subject: [PATCH 124/137] Fix evaluation of empty label_pattern --- netbox/dcim/forms.py | 16 ++++++++-------- netbox/utilities/forms.py | 29 ++--------------------------- 2 files changed, 10 insertions(+), 35 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1857e5f8f..df2625e67 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -139,14 +139,14 @@ class LabeledComponentForm(BootstrapMixin, forms.Form): def clean(self): # Validate that the number of components being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} components, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }, code='label_pattern_mismatch') + if self.cleaned_data['label_pattern']: + name_pattern_count = len(self.cleaned_data['name_pattern']) + label_pattern_count = len(self.cleaned_data['label_pattern']) + if name_pattern_count != label_pattern_count: + raise forms.ValidationError({ + 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however ' + f'{label_pattern_count} labels will be generated. These counts must match.' + }, code='label_pattern_mismatch') # diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index b2ce1592a..851efd24f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -529,8 +529,8 @@ class ExpandableNameField(forms.CharField): """ def to_python(self, value): - if value is None: - return list() + if not value: + return '' if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): return list(expand_alphanumeric_pattern(value)) return [value] @@ -830,31 +830,6 @@ class ImportForm(BootstrapMixin, forms.Form): }) -class LabeledComponentForm(BootstrapMixin, forms.Form): - """ - Base form for adding label pattern validation to `Create` forms - """ - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', - required=False - ) - - def clean(self): - - # Validate that the number of components being created from both the name_pattern and label_pattern are equal - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if label_pattern_count and name_pattern_count != label_pattern_count: - raise forms.ValidationError({ - 'label_pattern': 'The provided name pattern will create {} components, however {} labels will ' - 'be generated. These counts must match.'.format( - name_pattern_count, label_pattern_count) - }, code='label_pattern_mismatch') - - class TableConfigForm(BootstrapMixin, forms.Form): """ Form for configuring user's table preferences. From 0b1df1483fe19ed51f9cfc016bc901af1c4b82b8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 Jun 2020 16:34:53 -0400 Subject: [PATCH 125/137] 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 126/137] 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 4613b69c2829c1f51c846b13d75f075ea70e9d0d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 11:50:31 -0400 Subject: [PATCH 127/137] Extend GetReturnURLMixin to automatically resolve default return URL for querysets --- netbox/circuits/views.py | 13 ------ netbox/dcim/views.py | 84 ---------------------------------- netbox/extras/views.py | 9 ---- netbox/ipam/views.py | 38 --------------- netbox/secrets/views.py | 7 --- netbox/tenancy/views.py | 8 ---- netbox/utilities/views.py | 15 ++++-- netbox/virtualization/views.py | 17 ------- 8 files changed, 12 insertions(+), 179 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f100dd3c7..83b6fcd81 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -60,19 +60,16 @@ class ProviderEditView(ObjectEditView): queryset = Provider.objects.all() model_form = forms.ProviderForm template_name = 'circuits/provider_edit.html' - default_return_url = 'circuits:provider_list' class ProviderDeleteView(ObjectDeleteView): queryset = Provider.objects.all() - default_return_url = 'circuits:provider_list' class ProviderBulkImportView(BulkImportView): queryset = Provider.objects.all() model_form = forms.ProviderCSVForm table = tables.ProviderTable - default_return_url = 'circuits:provider_list' class ProviderBulkEditView(BulkEditView): @@ -80,14 +77,12 @@ class ProviderBulkEditView(BulkEditView): filterset = filters.ProviderFilterSet table = tables.ProviderTable form = forms.ProviderBulkEditForm - default_return_url = 'circuits:provider_list' class ProviderBulkDeleteView(BulkDeleteView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filterset = filters.ProviderFilterSet table = tables.ProviderTable - default_return_url = 'circuits:provider_list' # @@ -102,20 +97,17 @@ class CircuitTypeListView(ObjectListView): class CircuitTypeEditView(ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm - default_return_url = 'circuits:circuittype_list' class CircuitTypeBulkImportView(BulkImportView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeCSVForm table = tables.CircuitTypeTable - default_return_url = 'circuits:circuittype_list' class CircuitTypeBulkDeleteView(BulkDeleteView): queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable - default_return_url = 'circuits:circuittype_list' # @@ -165,19 +157,16 @@ class CircuitEditView(ObjectEditView): queryset = Circuit.objects.all() model_form = forms.CircuitForm template_name = 'circuits/circuit_edit.html' - default_return_url = 'circuits:circuit_list' class CircuitDeleteView(ObjectDeleteView): queryset = Circuit.objects.all() - default_return_url = 'circuits:circuit_list' class CircuitBulkImportView(BulkImportView): queryset = Circuit.objects.all() model_form = forms.CircuitCSVForm table = tables.CircuitTable - default_return_url = 'circuits:circuit_list' class CircuitBulkEditView(BulkEditView): @@ -185,14 +174,12 @@ class CircuitBulkEditView(BulkEditView): filterset = filters.CircuitFilterSet table = tables.CircuitTable form = forms.CircuitBulkEditForm - default_return_url = 'circuits:circuit_list' class CircuitBulkDeleteView(BulkDeleteView): queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filterset = filters.CircuitFilterSet table = tables.CircuitTable - default_return_url = 'circuits:circuit_list' class CircuitSwapTerminations(ObjectEditView): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 864665701..3edc9b061 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -120,21 +120,18 @@ class RegionListView(ObjectListView): class RegionEditView(ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm - default_return_url = 'dcim:region_list' class RegionBulkImportView(BulkImportView): queryset = Region.objects.all() model_form = forms.RegionCSVForm table = tables.RegionTable - default_return_url = 'dcim:region_list' class RegionBulkDeleteView(BulkDeleteView): queryset = Region.objects.all() filterset = filters.RegionFilterSet table = tables.RegionTable - default_return_url = 'dcim:region_list' # @@ -179,19 +176,16 @@ class SiteEditView(ObjectEditView): queryset = Site.objects.all() model_form = forms.SiteForm template_name = 'dcim/site_edit.html' - default_return_url = 'dcim:site_list' class SiteDeleteView(ObjectDeleteView): queryset = Site.objects.all() - default_return_url = 'dcim:site_list' class SiteBulkImportView(BulkImportView): queryset = Site.objects.all() model_form = forms.SiteCSVForm table = tables.SiteTable - default_return_url = 'dcim:site_list' class SiteBulkEditView(BulkEditView): @@ -199,14 +193,12 @@ class SiteBulkEditView(BulkEditView): filterset = filters.SiteFilterSet table = tables.SiteTable form = forms.SiteBulkEditForm - default_return_url = 'dcim:site_list' class SiteBulkDeleteView(BulkDeleteView): queryset = Site.objects.prefetch_related('region', 'tenant') filterset = filters.SiteFilterSet table = tables.SiteTable - default_return_url = 'dcim:site_list' # @@ -229,21 +221,18 @@ class RackGroupListView(ObjectListView): class RackGroupEditView(ObjectEditView): queryset = RackGroup.objects.all() model_form = forms.RackGroupForm - default_return_url = 'dcim:rackgroup_list' class RackGroupBulkImportView(BulkImportView): queryset = RackGroup.objects.all() model_form = forms.RackGroupCSVForm table = tables.RackGroupTable - default_return_url = 'dcim:rackgroup_list' class RackGroupBulkDeleteView(BulkDeleteView): queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks')) filterset = filters.RackGroupFilterSet table = tables.RackGroupTable - default_return_url = 'dcim:rackgroup_list' # @@ -258,20 +247,17 @@ class RackRoleListView(ObjectListView): class RackRoleEditView(ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm - default_return_url = 'dcim:rackrole_list' class RackRoleBulkImportView(BulkImportView): queryset = RackRole.objects.all() model_form = forms.RackRoleCSVForm table = tables.RackRoleTable - default_return_url = 'dcim:rackrole_list' class RackRoleBulkDeleteView(BulkDeleteView): queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable - default_return_url = 'dcim:rackrole_list' # @@ -363,19 +349,16 @@ class RackEditView(ObjectEditView): queryset = Rack.objects.all() model_form = forms.RackForm template_name = 'dcim/rack_edit.html' - default_return_url = 'dcim:rack_list' class RackDeleteView(ObjectDeleteView): queryset = Rack.objects.all() - default_return_url = 'dcim:rack_list' class RackBulkImportView(BulkImportView): queryset = Rack.objects.all() model_form = forms.RackCSVForm table = tables.RackTable - default_return_url = 'dcim:rack_list' class RackBulkEditView(BulkEditView): @@ -383,14 +366,12 @@ class RackBulkEditView(BulkEditView): filterset = filters.RackFilterSet table = tables.RackTable form = forms.RackBulkEditForm - default_return_url = 'dcim:rack_list' class RackBulkDeleteView(BulkDeleteView): queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable - default_return_url = 'dcim:rack_list' # @@ -421,7 +402,6 @@ class RackReservationEditView(ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm template_name = 'dcim/rackreservation_edit.html' - default_return_url = 'dcim:rackreservation_list' def alter_obj(self, obj, request, args, kwargs): if not obj.pk: @@ -433,14 +413,12 @@ class RackReservationEditView(ObjectEditView): class RackReservationDeleteView(ObjectDeleteView): queryset = RackReservation.objects.all() - default_return_url = 'dcim:rackreservation_list' class RackReservationImportView(BulkImportView): queryset = RackReservation.objects.all() model_form = forms.RackReservationCSVForm table = tables.RackReservationTable - default_return_url = 'dcim:rackreservation_list' def _save_obj(self, obj_form, request): """ @@ -458,14 +436,12 @@ class RackReservationBulkEditView(BulkEditView): filterset = filters.RackReservationFilterSet table = tables.RackReservationTable form = forms.RackReservationBulkEditForm - default_return_url = 'dcim:rackreservation_list' class RackReservationBulkDeleteView(BulkDeleteView): queryset = RackReservation.objects.prefetch_related('rack', 'user') filterset = filters.RackReservationFilterSet table = tables.RackReservationTable - default_return_url = 'dcim:rackreservation_list' # @@ -484,20 +460,17 @@ class ManufacturerListView(ObjectListView): class ManufacturerEditView(ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm - default_return_url = 'dcim:manufacturer_list' class ManufacturerBulkImportView(BulkImportView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerCSVForm table = tables.ManufacturerTable - default_return_url = 'dcim:manufacturer_list' class ManufacturerBulkDeleteView(BulkDeleteView): queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) table = tables.ManufacturerTable - default_return_url = 'dcim:manufacturer_list' # @@ -580,12 +553,10 @@ class DeviceTypeEditView(ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' - default_return_url = 'dcim:devicetype_list' class DeviceTypeDeleteView(ObjectDeleteView): queryset = DeviceType.objects.all() - default_return_url = 'dcim:devicetype_list' class DeviceTypeImportView(ObjectImportView): @@ -612,7 +583,6 @@ class DeviceTypeImportView(ObjectImportView): ('front-ports', forms.FrontPortTemplateImportForm), ('device-bays', forms.DeviceBayTemplateImportForm), )) - default_return_url = 'dcim:devicetype_import' class DeviceTypeBulkEditView(BulkEditView): @@ -620,14 +590,12 @@ class DeviceTypeBulkEditView(BulkEditView): filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm - default_return_url = 'dcim:devicetype_list' class DeviceTypeBulkDeleteView(BulkDeleteView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances')) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable - default_return_url = 'dcim:devicetype_list' # @@ -890,20 +858,17 @@ class DeviceRoleListView(ObjectListView): class DeviceRoleEditView(ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm - default_return_url = 'dcim:devicerole_list' class DeviceRoleBulkImportView(BulkImportView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleCSVForm table = tables.DeviceRoleTable - default_return_url = 'dcim:devicerole_list' class DeviceRoleBulkDeleteView(BulkDeleteView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable - default_return_url = 'dcim:devicerole_list' # @@ -918,20 +883,17 @@ class PlatformListView(ObjectListView): class PlatformEditView(ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm - default_return_url = 'dcim:platform_list' class PlatformBulkImportView(BulkImportView): queryset = Platform.objects.all() model_form = forms.PlatformCSVForm table = tables.PlatformTable - default_return_url = 'dcim:platform_list' class PlatformBulkDeleteView(BulkDeleteView): queryset = Platform.objects.all() table = tables.PlatformTable - default_return_url = 'dcim:platform_list' # @@ -1118,12 +1080,10 @@ class DeviceEditView(ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm template_name = 'dcim/device_edit.html' - default_return_url = 'dcim:device_list' class DeviceDeleteView(ObjectDeleteView): queryset = Device.objects.all() - default_return_url = 'dcim:device_list' class DeviceBulkImportView(BulkImportView): @@ -1131,7 +1091,6 @@ class DeviceBulkImportView(BulkImportView): model_form = forms.DeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' - default_return_url = 'dcim:device_list' class ChildDeviceBulkImportView(BulkImportView): @@ -1139,7 +1098,6 @@ class ChildDeviceBulkImportView(BulkImportView): model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' - default_return_url = 'dcim:device_list' def _save_obj(self, obj_form, request): @@ -1158,14 +1116,12 @@ class DeviceBulkEditView(BulkEditView): filterset = filters.DeviceFilterSet table = tables.DeviceTable form = forms.DeviceBulkEditForm - default_return_url = 'dcim:device_list' class DeviceBulkDeleteView(BulkDeleteView): queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filterset = filters.DeviceFilterSet table = tables.DeviceTable - default_return_url = 'dcim:device_list' # @@ -1204,7 +1160,6 @@ class ConsolePortBulkImportView(BulkImportView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortCSVForm table = tables.ConsolePortTable - default_return_url = 'dcim:consoleport_list' class ConsolePortBulkEditView(BulkEditView): @@ -1226,7 +1181,6 @@ class ConsolePortBulkDeleteView(BulkDeleteView): queryset = ConsolePort.objects.all() filterset = filters.ConsolePortFilterSet table = tables.ConsolePortTable - default_return_url = 'dcim:consoleport_list' # @@ -1265,7 +1219,6 @@ class ConsoleServerPortBulkImportView(BulkImportView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortCSVForm table = tables.ConsoleServerPortTable - default_return_url = 'dcim:consoleserverport_list' class ConsoleServerPortBulkEditView(BulkEditView): @@ -1287,7 +1240,6 @@ class ConsoleServerPortBulkDeleteView(BulkDeleteView): queryset = ConsoleServerPort.objects.all() filterset = filters.ConsoleServerPortFilterSet table = tables.ConsoleServerPortTable - default_return_url = 'dcim:consoleserverport_list' # @@ -1326,7 +1278,6 @@ class PowerPortBulkImportView(BulkImportView): queryset = PowerPort.objects.all() model_form = forms.PowerPortCSVForm table = tables.PowerPortTable - default_return_url = 'dcim:powerport_list' class PowerPortBulkEditView(BulkEditView): @@ -1348,7 +1299,6 @@ class PowerPortBulkDeleteView(BulkDeleteView): queryset = PowerPort.objects.all() filterset = filters.PowerPortFilterSet table = tables.PowerPortTable - default_return_url = 'dcim:powerport_list' # @@ -1387,7 +1337,6 @@ class PowerOutletBulkImportView(BulkImportView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletCSVForm table = tables.PowerOutletTable - default_return_url = 'dcim:poweroutlet_list' class PowerOutletBulkEditView(BulkEditView): @@ -1409,7 +1358,6 @@ class PowerOutletBulkDeleteView(BulkDeleteView): queryset = PowerOutlet.objects.all() filterset = filters.PowerOutletFilterSet table = tables.PowerOutletTable - default_return_url = 'dcim:poweroutlet_list' # @@ -1481,7 +1429,6 @@ class InterfaceBulkImportView(BulkImportView): queryset = Interface.objects.all() model_form = forms.InterfaceCSVForm table = tables.InterfaceTable - default_return_url = 'dcim:interface_list' class InterfaceBulkEditView(BulkEditView): @@ -1503,7 +1450,6 @@ class InterfaceBulkDeleteView(BulkDeleteView): queryset = Interface.objects.all() filterset = filters.InterfaceFilterSet table = tables.InterfaceTable - default_return_url = 'dcim:interface_list' # @@ -1542,7 +1488,6 @@ class FrontPortBulkImportView(BulkImportView): queryset = FrontPort.objects.all() model_form = forms.FrontPortCSVForm table = tables.FrontPortTable - default_return_url = 'dcim:frontport_list' class FrontPortBulkEditView(BulkEditView): @@ -1564,7 +1509,6 @@ class FrontPortBulkDeleteView(BulkDeleteView): queryset = FrontPort.objects.all() filterset = filters.FrontPortFilterSet table = tables.FrontPortTable - default_return_url = 'dcim:frontport_list' # @@ -1603,7 +1547,6 @@ class RearPortBulkImportView(BulkImportView): queryset = RearPort.objects.all() model_form = forms.RearPortCSVForm table = tables.RearPortTable - default_return_url = 'dcim:rearport_list' class RearPortBulkEditView(BulkEditView): @@ -1625,7 +1568,6 @@ class RearPortBulkDeleteView(BulkDeleteView): queryset = RearPort.objects.all() filterset = filters.RearPortFilterSet table = tables.RearPortTable - default_return_url = 'dcim:rearport_list' # @@ -1731,7 +1673,6 @@ class DeviceBayBulkImportView(BulkImportView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayCSVForm table = tables.DeviceBayTable - default_return_url = 'dcim:devicebay_list' class DeviceBayBulkEditView(BulkEditView): @@ -1749,7 +1690,6 @@ class DeviceBayBulkDeleteView(BulkDeleteView): queryset = DeviceBay.objects.all() filterset = filters.DeviceBayFilterSet table = tables.DeviceBayTable - default_return_url = 'dcim:devicebay_list' # @@ -1901,7 +1841,6 @@ class CableTraceView(ObjectView): class CableCreateView(ObjectEditView): queryset = Cable.objects.all() template_name = 'dcim/cable_connect.html' - default_return_url = 'dcim:cable_list' def dispatch(self, request, *args, **kwargs): @@ -1959,19 +1898,16 @@ class CableEditView(ObjectEditView): queryset = Cable.objects.all() model_form = forms.CableForm template_name = 'dcim/cable_edit.html' - default_return_url = 'dcim:cable_list' class CableDeleteView(ObjectDeleteView): queryset = Cable.objects.all() - default_return_url = 'dcim:cable_list' class CableBulkImportView(BulkImportView): queryset = Cable.objects.all() model_form = forms.CableCSVForm table = tables.CableTable - default_return_url = 'dcim:cable_list' class CableBulkEditView(BulkEditView): @@ -1979,14 +1915,12 @@ class CableBulkEditView(BulkEditView): filterset = filters.CableFilterSet table = tables.CableTable form = forms.CableBulkEditForm - default_return_url = 'dcim:cable_list' class CableBulkDeleteView(BulkDeleteView): queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') filterset = filters.CableFilterSet table = tables.CableTable - default_return_url = 'dcim:cable_list' # @@ -2122,7 +2056,6 @@ class InventoryItemBulkImportView(BulkImportView): queryset = InventoryItem.objects.all() model_form = forms.InventoryItemCSVForm table = tables.InventoryItemTable - default_return_url = 'dcim:inventoryitem_list' class InventoryItemBulkEditView(BulkEditView): @@ -2130,14 +2063,12 @@ class InventoryItemBulkEditView(BulkEditView): filterset = filters.InventoryItemFilterSet table = tables.InventoryItemTable form = forms.InventoryItemBulkEditForm - default_return_url = 'dcim:inventoryitem_list' class InventoryItemBulkDeleteView(BulkDeleteView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' - default_return_url = 'dcim:inventoryitem_list' # @@ -2169,7 +2100,6 @@ class VirtualChassisCreateView(ObjectEditView): queryset = VirtualChassis.objects.all() model_form = forms.VirtualChassisCreateForm template_name = 'dcim/virtualchassis_add.html' - default_return_url = 'dcim:virtualchassis_list' class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): @@ -2242,7 +2172,6 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V class VirtualChassisDeleteView(ObjectDeleteView): queryset = VirtualChassis.objects.all() - default_return_url = 'dcim:device_list' class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View): @@ -2356,7 +2285,6 @@ class VirtualChassisBulkImportView(BulkImportView): queryset = VirtualChassis.objects.all() model_form = forms.VirtualChassisCSVForm table = tables.VirtualChassisTable - default_return_url = 'dcim:virtualchassis_list' class VirtualChassisBulkEditView(BulkEditView): @@ -2364,14 +2292,12 @@ class VirtualChassisBulkEditView(BulkEditView): filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable form = forms.VirtualChassisBulkEditForm - default_return_url = 'dcim:virtualchassis_list' class VirtualChassisBulkDeleteView(BulkDeleteView): queryset = VirtualChassis.objects.all() filterset = filters.VirtualChassisFilterSet table = tables.VirtualChassisTable - default_return_url = 'dcim:virtualchassis_list' # @@ -2411,19 +2337,16 @@ class PowerPanelView(ObjectView): class PowerPanelEditView(ObjectEditView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm - default_return_url = 'dcim:powerpanel_list' class PowerPanelDeleteView(ObjectDeleteView): queryset = PowerPanel.objects.all() - default_return_url = 'dcim:powerpanel_list' class PowerPanelBulkImportView(BulkImportView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelCSVForm table = tables.PowerPanelTable - default_return_url = 'dcim:powerpanel_list' class PowerPanelBulkEditView(BulkEditView): @@ -2431,7 +2354,6 @@ class PowerPanelBulkEditView(BulkEditView): filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable form = forms.PowerPanelBulkEditForm - default_return_url = 'dcim:powerpanel_list' class PowerPanelBulkDeleteView(BulkDeleteView): @@ -2442,7 +2364,6 @@ class PowerPanelBulkDeleteView(BulkDeleteView): ) filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable - default_return_url = 'dcim:powerpanel_list' # @@ -2474,19 +2395,16 @@ class PowerFeedEditView(ObjectEditView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm template_name = 'dcim/powerfeed_edit.html' - default_return_url = 'dcim:powerfeed_list' class PowerFeedDeleteView(ObjectDeleteView): queryset = PowerFeed.objects.all() - default_return_url = 'dcim:powerfeed_list' class PowerFeedBulkImportView(BulkImportView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedCSVForm table = tables.PowerFeedTable - default_return_url = 'dcim:powerfeed_list' class PowerFeedBulkEditView(BulkEditView): @@ -2494,11 +2412,9 @@ class PowerFeedBulkEditView(BulkEditView): filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable form = forms.PowerFeedBulkEditForm - default_return_url = 'dcim:powerfeed_list' class PowerFeedBulkDeleteView(BulkDeleteView): queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') filterset = filters.PowerFeedFilterSet table = tables.PowerFeedTable - default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 55064b5cf..879ca89d0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -70,20 +70,17 @@ class TagView(ObjectView): class TagEditView(ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm - default_return_url = 'extras:tag_list' template_name = 'extras/tag_edit.html' class TagDeleteView(ObjectDeleteView): queryset = Tag.objects.all() - default_return_url = 'extras:tag_list' class TagBulkImportView(BulkImportView): queryset = Tag.objects.all() model_form = forms.TagCSVForm table = tables.TagTable - default_return_url = 'extras:tag_list' class TagBulkEditView(BulkEditView): @@ -94,7 +91,6 @@ class TagBulkEditView(BulkEditView): ) table = tables.TagTable form = forms.TagBulkEditForm - default_return_url = 'extras:tag_list' class TagBulkDeleteView(BulkDeleteView): @@ -104,7 +100,6 @@ class TagBulkDeleteView(BulkDeleteView): 'name' ) table = tables.TagTable - default_return_url = 'extras:tag_list' # @@ -156,7 +151,6 @@ class ConfigContextView(ObjectView): class ConfigContextEditView(ObjectEditView): queryset = ConfigContext.objects.all() model_form = forms.ConfigContextForm - default_return_url = 'extras:configcontext_list' template_name = 'extras/configcontext_edit.html' @@ -165,18 +159,15 @@ class ConfigContextBulkEditView(BulkEditView): filterset = filters.ConfigContextFilterSet table = tables.ConfigContextTable form = forms.ConfigContextBulkEditForm - default_return_url = 'extras:configcontext_list' class ConfigContextDeleteView(ObjectDeleteView): queryset = ConfigContext.objects.all() - default_return_url = 'extras:configcontext_list' class ConfigContextBulkDeleteView(BulkDeleteView): queryset = ConfigContext.objects.all() table = tables.ConfigContextTable - default_return_url = 'extras:configcontext_list' class ObjectConfigContextView(ObjectView): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index e33cb3c6e..81e210958 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -48,19 +48,16 @@ class VRFEditView(ObjectEditView): queryset = VRF.objects.all() model_form = forms.VRFForm template_name = 'ipam/vrf_edit.html' - default_return_url = 'ipam:vrf_list' class VRFDeleteView(ObjectDeleteView): queryset = VRF.objects.all() - default_return_url = 'ipam:vrf_list' class VRFBulkImportView(BulkImportView): queryset = VRF.objects.all() model_form = forms.VRFCSVForm table = tables.VRFTable - default_return_url = 'ipam:vrf_list' class VRFBulkEditView(BulkEditView): @@ -68,14 +65,12 @@ class VRFBulkEditView(BulkEditView): filterset = filters.VRFFilterSet table = tables.VRFTable form = forms.VRFBulkEditForm - default_return_url = 'ipam:vrf_list' class VRFBulkDeleteView(BulkDeleteView): queryset = VRF.objects.prefetch_related('tenant') filterset = filters.VRFFilterSet table = tables.VRFTable - default_return_url = 'ipam:vrf_list' # @@ -163,21 +158,18 @@ class RIRListView(ObjectListView): class RIREditView(ObjectEditView): queryset = RIR.objects.all() model_form = forms.RIRForm - default_return_url = 'ipam:rir_list' class RIRBulkImportView(BulkImportView): queryset = RIR.objects.all() model_form = forms.RIRCSVForm table = tables.RIRTable - default_return_url = 'ipam:rir_list' class RIRBulkDeleteView(BulkDeleteView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filterset = filters.RIRFilterSet table = tables.RIRTable - default_return_url = 'ipam:rir_list' # @@ -259,19 +251,16 @@ class AggregateEditView(ObjectEditView): queryset = Aggregate.objects.all() model_form = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' - default_return_url = 'ipam:aggregate_list' class AggregateDeleteView(ObjectDeleteView): queryset = Aggregate.objects.all() - default_return_url = 'ipam:aggregate_list' class AggregateBulkImportView(BulkImportView): queryset = Aggregate.objects.all() model_form = forms.AggregateCSVForm table = tables.AggregateTable - default_return_url = 'ipam:aggregate_list' class AggregateBulkEditView(BulkEditView): @@ -279,14 +268,12 @@ class AggregateBulkEditView(BulkEditView): filterset = filters.AggregateFilterSet table = tables.AggregateTable form = forms.AggregateBulkEditForm - default_return_url = 'ipam:aggregate_list' class AggregateBulkDeleteView(BulkDeleteView): queryset = Aggregate.objects.prefetch_related('rir') filterset = filters.AggregateFilterSet table = tables.AggregateTable - default_return_url = 'ipam:aggregate_list' # @@ -301,20 +288,17 @@ class RoleListView(ObjectListView): class RoleEditView(ObjectEditView): queryset = Role.objects.all() model_form = forms.RoleForm - default_return_url = 'ipam:role_list' class RoleBulkImportView(BulkImportView): queryset = Role.objects.all() model_form = forms.RoleCSVForm table = tables.RoleTable - default_return_url = 'ipam:role_list' class RoleBulkDeleteView(BulkDeleteView): queryset = Role.objects.all() table = tables.RoleTable - default_return_url = 'ipam:role_list' # @@ -470,20 +454,17 @@ class PrefixEditView(ObjectEditView): queryset = Prefix.objects.all() model_form = forms.PrefixForm template_name = 'ipam/prefix_edit.html' - default_return_url = 'ipam:prefix_list' class PrefixDeleteView(ObjectDeleteView): queryset = Prefix.objects.all() template_name = 'ipam/prefix_delete.html' - default_return_url = 'ipam:prefix_list' class PrefixBulkImportView(BulkImportView): queryset = Prefix.objects.all() model_form = forms.PrefixCSVForm table = tables.PrefixTable - default_return_url = 'ipam:prefix_list' class PrefixBulkEditView(BulkEditView): @@ -491,14 +472,12 @@ class PrefixBulkEditView(BulkEditView): filterset = filters.PrefixFilterSet table = tables.PrefixTable form = forms.PrefixBulkEditForm - default_return_url = 'ipam:prefix_list' class PrefixBulkDeleteView(BulkDeleteView): queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filterset = filters.PrefixFilterSet table = tables.PrefixTable - default_return_url = 'ipam:prefix_list' # @@ -569,7 +548,6 @@ class IPAddressEditView(ObjectEditView): queryset = IPAddress.objects.all() model_form = forms.IPAddressForm template_name = 'ipam/ipaddress_edit.html' - default_return_url = 'ipam:ipaddress_list' def alter_obj(self, obj, request, url_args, url_kwargs): @@ -630,7 +608,6 @@ class IPAddressAssignView(ObjectView): class IPAddressDeleteView(ObjectDeleteView): queryset = IPAddress.objects.all() - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkCreateView(BulkCreateView): @@ -639,14 +616,12 @@ class IPAddressBulkCreateView(BulkCreateView): model_form = forms.IPAddressBulkAddForm pattern_target = 'address' template_name = 'ipam/ipaddress_bulk_add.html' - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkImportView(BulkImportView): queryset = IPAddress.objects.all() model_form = forms.IPAddressCSVForm table = tables.IPAddressTable - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkEditView(BulkEditView): @@ -654,14 +629,12 @@ class IPAddressBulkEditView(BulkEditView): filterset = filters.IPAddressFilterSet table = tables.IPAddressTable form = forms.IPAddressBulkEditForm - default_return_url = 'ipam:ipaddress_list' class IPAddressBulkDeleteView(BulkDeleteView): queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') filterset = filters.IPAddressFilterSet table = tables.IPAddressTable - default_return_url = 'ipam:ipaddress_list' # @@ -678,21 +651,18 @@ class VLANGroupListView(ObjectListView): class VLANGroupEditView(ObjectEditView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm - default_return_url = 'ipam:vlangroup_list' class VLANGroupBulkImportView(BulkImportView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupCSVForm table = tables.VLANGroupTable - default_return_url = 'ipam:vlangroup_list' class VLANGroupBulkDeleteView(BulkDeleteView): queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) filterset = filters.VLANGroupFilterSet table = tables.VLANGroupTable - default_return_url = 'ipam:vlangroup_list' class VLANGroupVLANsView(ObjectView): @@ -789,19 +759,16 @@ class VLANEditView(ObjectEditView): queryset = VLAN.objects.all() model_form = forms.VLANForm template_name = 'ipam/vlan_edit.html' - default_return_url = 'ipam:vlan_list' class VLANDeleteView(ObjectDeleteView): queryset = VLAN.objects.all() - default_return_url = 'ipam:vlan_list' class VLANBulkImportView(BulkImportView): queryset = VLAN.objects.all() model_form = forms.VLANCSVForm table = tables.VLANTable - default_return_url = 'ipam:vlan_list' class VLANBulkEditView(BulkEditView): @@ -809,14 +776,12 @@ class VLANBulkEditView(BulkEditView): filterset = filters.VLANFilterSet table = tables.VLANTable form = forms.VLANBulkEditForm - default_return_url = 'ipam:vlan_list' class VLANBulkDeleteView(BulkDeleteView): queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') filterset = filters.VLANFilterSet table = tables.VLANTable - default_return_url = 'ipam:vlan_list' # @@ -863,7 +828,6 @@ class ServiceBulkImportView(BulkImportView): queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable - default_return_url = 'ipam:service_list' class ServiceDeleteView(ObjectDeleteView): @@ -875,11 +839,9 @@ class ServiceBulkEditView(BulkEditView): filterset = filters.ServiceFilterSet table = tables.ServiceTable form = forms.ServiceBulkEditForm - default_return_url = 'ipam:service_list' class ServiceBulkDeleteView(BulkDeleteView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filters.ServiceFilterSet table = tables.ServiceTable - default_return_url = 'ipam:service_list' diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index a5aabaecd..fec7c65d1 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -36,20 +36,17 @@ class SecretRoleListView(ObjectListView): class SecretRoleEditView(ObjectEditView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm - default_return_url = 'secrets:secretrole_list' class SecretRoleBulkImportView(BulkImportView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' class SecretRoleBulkDeleteView(BulkDeleteView): queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' # @@ -147,7 +144,6 @@ class SecretEditView(ObjectEditView): class SecretDeleteView(ObjectDeleteView): queryset = Secret.objects.all() - default_return_url = 'secrets:secret_list' class SecretBulkImportView(BulkImportView): @@ -155,7 +151,6 @@ class SecretBulkImportView(BulkImportView): model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' - default_return_url = 'secrets:secret_list' widget_attrs = {'class': 'requires-session-key'} master_key = None @@ -203,11 +198,9 @@ class SecretBulkEditView(BulkEditView): filterset = filters.SecretFilterSet table = tables.SecretTable form = forms.SecretBulkEditForm - default_return_url = 'secrets:secret_list' class SecretBulkDeleteView(BulkDeleteView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable - default_return_url = 'secrets:secret_list' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index a82b231f5..26b1ce027 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -30,20 +30,17 @@ class TenantGroupListView(ObjectListView): class TenantGroupEditView(ObjectEditView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm - default_return_url = 'tenancy:tenantgroup_list' class TenantGroupBulkImportView(BulkImportView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' class TenantGroupBulkDeleteView(BulkDeleteView): queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' # @@ -87,19 +84,16 @@ class TenantEditView(ObjectEditView): queryset = Tenant.objects.all() model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' - default_return_url = 'tenancy:tenant_list' class TenantDeleteView(ObjectDeleteView): queryset = Tenant.objects.all() - default_return_url = 'tenancy:tenant_list' class TenantBulkImportView(BulkImportView): queryset = Tenant.objects.all() model_form = forms.TenantCSVForm table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' class TenantBulkEditView(BulkEditView): @@ -107,11 +101,9 @@ class TenantBulkEditView(BulkEditView): filterset = filters.TenantFilterSet table = tables.TenantTable form = forms.TenantBulkEditForm - default_return_url = 'tenancy:tenant_list' class TenantBulkDeleteView(BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 6596660ce..f06fb622b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -16,6 +16,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse +from django.urls.exceptions import NoReverseMatch from django.utils.decorators import method_decorator from django.utils.html import escape from django.utils.http import is_safe_url @@ -86,7 +87,7 @@ class ObjectPermissionRequiredMixin(AccessMixin): return super().dispatch(request, *args, **kwargs) -class GetReturnURLMixin(object): +class GetReturnURLMixin: """ Provides logic for determining where a user should be redirected after processing a form. """ @@ -101,13 +102,21 @@ class GetReturnURLMixin(object): return query_param # Next, check if the object being modified (if any) has an absolute URL. - elif obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): + if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): return obj.get_absolute_url() # Fall back to the default URL (if specified) for the view. - elif self.default_return_url is not None: + if self.default_return_url is not None: return reverse(self.default_return_url) + # Attempt to dynamically resolve the list view for the object + if hasattr(self, 'queryset'): + model_opts = self.queryset.model._meta + try: + return reverse(f'{model_opts.app_label}:{model_opts.model_name}_list') + except NoReverseMatch: + pass + # If all else fails, return home. Ideally this should never happen. return reverse('home') diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index fadd614c3..478fead94 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -29,20 +29,17 @@ class ClusterTypeListView(ObjectListView): class ClusterTypeEditView(ObjectEditView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeForm - default_return_url = 'virtualization:clustertype_list' class ClusterTypeBulkImportView(BulkImportView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeCSVForm table = tables.ClusterTypeTable - default_return_url = 'virtualization:clustertype_list' class ClusterTypeBulkDeleteView(BulkDeleteView): queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable - default_return_url = 'virtualization:clustertype_list' # @@ -57,20 +54,17 @@ class ClusterGroupListView(ObjectListView): class ClusterGroupEditView(ObjectEditView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupForm - default_return_url = 'virtualization:clustergroup_list' class ClusterGroupBulkImportView(BulkImportView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable - default_return_url = 'virtualization:clustergroup_list' class ClusterGroupBulkDeleteView(BulkDeleteView): queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable - default_return_url = 'virtualization:clustergroup_list' # @@ -114,14 +108,12 @@ class ClusterEditView(ObjectEditView): class ClusterDeleteView(ObjectDeleteView): queryset = Cluster.objects.all() - default_return_url = 'virtualization:cluster_list' class ClusterBulkImportView(BulkImportView): queryset = Cluster.objects.all() model_form = forms.ClusterCSVForm table = tables.ClusterTable - default_return_url = 'virtualization:cluster_list' class ClusterBulkEditView(BulkEditView): @@ -129,14 +121,12 @@ class ClusterBulkEditView(BulkEditView): filterset = filters.ClusterFilterSet table = tables.ClusterTable form = forms.ClusterBulkEditForm - default_return_url = 'virtualization:cluster_list' class ClusterBulkDeleteView(BulkDeleteView): queryset = Cluster.objects.prefetch_related('type', 'group', 'site') filterset = filters.ClusterFilterSet table = tables.ClusterTable - default_return_url = 'virtualization:cluster_list' class ClusterAddDevicesView(ObjectEditView): @@ -266,19 +256,16 @@ class VirtualMachineEditView(ObjectEditView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineForm template_name = 'virtualization/virtualmachine_edit.html' - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineDeleteView(ObjectDeleteView): queryset = VirtualMachine.objects.all() - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineBulkImportView(BulkImportView): queryset = VirtualMachine.objects.all() model_form = forms.VirtualMachineCSVForm table = tables.VirtualMachineTable - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineBulkEditView(BulkEditView): @@ -286,14 +273,12 @@ class VirtualMachineBulkEditView(BulkEditView): filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm - default_return_url = 'virtualization:virtualmachine_list' class VirtualMachineBulkDeleteView(BulkDeleteView): queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable - default_return_url = 'virtualization:virtualmachine_list' # @@ -364,7 +349,6 @@ class VMInterfaceBulkImportView(BulkImportView): queryset = VMInterface.objects.all() model_form = forms.VMInterfaceCSVForm table = tables.VMInterfaceTable - default_return_url = 'virtualization:vminterface_list' class VMInterfaceBulkEditView(BulkEditView): @@ -395,4 +379,3 @@ class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView): model_form = forms.VMInterfaceForm filterset = filters.VirtualMachineFilterSet table = tables.VirtualMachineTable - default_return_url = 'virtualization:virtualmachine_list' From 8959d2e0a70d7e5063ceb14121e8d871977f2034 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 12:08:26 -0400 Subject: [PATCH 128/137] #4416: Add individual delete views for organizational objects --- netbox/circuits/urls.py | 1 + netbox/circuits/views.py | 4 ++++ netbox/dcim/urls.py | 6 ++++++ netbox/dcim/views.py | 24 ++++++++++++++++++++++++ netbox/ipam/urls.py | 3 +++ netbox/ipam/views.py | 12 ++++++++++++ netbox/secrets/urls.py | 1 + netbox/secrets/views.py | 4 ++++ netbox/tenancy/urls.py | 1 + netbox/tenancy/views.py | 4 ++++ netbox/utilities/testing/views.py | 1 + netbox/virtualization/urls.py | 2 ++ netbox/virtualization/views.py | 8 ++++++++ 13 files changed, 71 insertions(+) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 1c0f0715b..86ea55fa8 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 83b6fcd81..e2dc80816 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -99,6 +99,10 @@ class CircuitTypeEditView(ObjectEditView): model_form = forms.CircuitTypeForm +class CircuitTypeDeleteView(ObjectDeleteView): + queryset = CircuitType.objects.all() + + class CircuitTypeBulkImportView(BulkImportView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeCSVForm diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 087f9db62..4d5964ee1 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), + path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites @@ -38,6 +39,7 @@ urlpatterns = [ path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + path('rack-groups//delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'), path('rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles @@ -46,6 +48,7 @@ urlpatterns = [ path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), + path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations @@ -78,6 +81,7 @@ urlpatterns = [ path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types @@ -153,6 +157,7 @@ urlpatterns = [ path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms @@ -161,6 +166,7 @@ urlpatterns = [ path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3edc9b061..3eebbabae 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -122,6 +122,10 @@ class RegionEditView(ObjectEditView): model_form = forms.RegionForm +class RegionDeleteView(ObjectDeleteView): + queryset = Region.objects.all() + + class RegionBulkImportView(BulkImportView): queryset = Region.objects.all() model_form = forms.RegionCSVForm @@ -223,6 +227,10 @@ class RackGroupEditView(ObjectEditView): model_form = forms.RackGroupForm +class RackGroupDeleteView(ObjectDeleteView): + queryset = RackGroup.objects.all() + + class RackGroupBulkImportView(BulkImportView): queryset = RackGroup.objects.all() model_form = forms.RackGroupCSVForm @@ -249,6 +257,10 @@ class RackRoleEditView(ObjectEditView): model_form = forms.RackRoleForm +class RackRoleDeleteView(ObjectDeleteView): + queryset = RackRole.objects.all() + + class RackRoleBulkImportView(BulkImportView): queryset = RackRole.objects.all() model_form = forms.RackRoleCSVForm @@ -462,6 +474,10 @@ class ManufacturerEditView(ObjectEditView): model_form = forms.ManufacturerForm +class ManufacturerDeleteView(ObjectDeleteView): + queryset = Manufacturer.objects.all() + + class ManufacturerBulkImportView(BulkImportView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerCSVForm @@ -860,6 +876,10 @@ class DeviceRoleEditView(ObjectEditView): model_form = forms.DeviceRoleForm +class DeviceRoleDeleteView(ObjectDeleteView): + queryset = DeviceRole.objects.all() + + class DeviceRoleBulkImportView(BulkImportView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleCSVForm @@ -885,6 +905,10 @@ class PlatformEditView(ObjectEditView): model_form = forms.PlatformForm +class PlatformDeleteView(ObjectDeleteView): + queryset = Platform.objects.all() + + class PlatformBulkImportView(BulkImportView): queryset = Platform.objects.all() model_form = forms.PlatformCSVForm diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index de8fc86eb..b2080c0a8 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates @@ -43,6 +44,7 @@ urlpatterns = [ path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes @@ -77,6 +79,7 @@ urlpatterns = [ path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), path('vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 81e210958..5c0df3a16 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -160,6 +160,10 @@ class RIREditView(ObjectEditView): model_form = forms.RIRForm +class RIRDeleteView(ObjectDeleteView): + queryset = RIR.objects.all() + + class RIRBulkImportView(BulkImportView): queryset = RIR.objects.all() model_form = forms.RIRCSVForm @@ -290,6 +294,10 @@ class RoleEditView(ObjectEditView): model_form = forms.RoleForm +class RoleDeleteView(ObjectDeleteView): + queryset = Role.objects.all() + + class RoleBulkImportView(BulkImportView): queryset = Role.objects.all() model_form = forms.RoleCSVForm @@ -653,6 +661,10 @@ class VLANGroupEditView(ObjectEditView): model_form = forms.VLANGroupForm +class VLANGroupDeleteView(ObjectDeleteView): + queryset = VLANGroup.objects.all() + + class VLANGroupBulkImportView(BulkImportView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupCSVForm diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 84c2da398..9dbb5d044 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index fec7c65d1..e9ea1835f 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -38,6 +38,10 @@ class SecretRoleEditView(ObjectEditView): model_form = forms.SecretRoleForm +class SecretRoleDeleteView(ObjectDeleteView): + queryset = SecretRole.objects.all() + + class SecretRoleBulkImportView(BulkImportView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 4c65ce4e8..372308bb8 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 26b1ce027..9ef44206c 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -32,6 +32,10 @@ class TenantGroupEditView(ObjectEditView): model_form = forms.TenantGroupForm +class TenantGroupDeleteView(ObjectDeleteView): + queryset = TenantGroup.objects.all() + + class TenantGroupBulkImportView(BulkImportView): queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 12c811396..b4ca52246 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -905,6 +905,7 @@ class ViewTestCases: GetObjectChangelogViewTestCase, CreateObjectViewTestCase, EditObjectViewTestCase, + DeleteObjectViewTestCase, ListObjectsViewTestCase, BulkImportObjectsViewTestCase, BulkDeleteObjectsViewTestCase, diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 34172ee88..3d6f07566 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), + path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), path('cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), # Cluster groups @@ -22,6 +23,7 @@ urlpatterns = [ path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), + path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), path('cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), # Clusters diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 478fead94..176c89f2e 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -31,6 +31,10 @@ class ClusterTypeEditView(ObjectEditView): model_form = forms.ClusterTypeForm +class ClusterTypeDeleteView(ObjectDeleteView): + queryset = ClusterType.objects.all() + + class ClusterTypeBulkImportView(BulkImportView): queryset = ClusterType.objects.all() model_form = forms.ClusterTypeCSVForm @@ -56,6 +60,10 @@ class ClusterGroupEditView(ObjectEditView): model_form = forms.ClusterGroupForm +class ClusterGroupDeleteView(ObjectDeleteView): + queryset = ClusterGroup.objects.all() + + class ClusterGroupBulkImportView(BulkImportView): queryset = ClusterGroup.objects.all() model_form = forms.ClusterGroupCSVForm From c484fa99e24313ab258e963f0066d17a5e9e79df Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 13:47:01 -0400 Subject: [PATCH 129/137] Introduce ButtonsColumn to reduce boilerplate and standardize organizational object links --- netbox/circuits/tables.py | 18 +---- netbox/dcim/tables.py | 112 ++++---------------------------- netbox/extras/tables.py | 20 +----- netbox/ipam/tables.py | 47 ++------------ netbox/secrets/tables.py | 17 +---- netbox/tenancy/tables.py | 17 +---- netbox/utilities/tables.py | 43 ++++++++++++ netbox/virtualization/tables.py | 32 +-------- 8 files changed, 75 insertions(+), 231 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index ea17031a1..ce3368f31 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -2,19 +2,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import Circuit, CircuitType, Provider -CIRCUITTYPE_ACTIONS = """ -
    - - -{% if perms.circuit.change_circuittype %} - -{% endif %} -""" - STATUS_LABEL = """ {{ record.get_status_display }} """ @@ -53,11 +43,7 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = tables.TemplateColumn( - template_code=CIRCUITTYPE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(CircuitType, pk_field='slug') class Meta(BaseTable.Meta): model = CircuitType diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index dd6b96406..871679fc7 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -2,7 +2,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn +from utilities.tables import ( + BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn, +) from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -40,69 +42,16 @@ DEVICE_LINK = """ """ -REGION_ACTIONS = """ - - - -{% if perms.dcim.change_region %} - -{% endif %} -""" - -RACKGROUP_ACTIONS = """ - - - +RACKGROUP_ELEVATIONS = """ -{% if perms.dcim.change_rackgroup %} - - - -{% endif %} -""" - -RACKROLE_ACTIONS = """ - - - -{% if perms.dcim.change_rackrole %} - -{% endif %} """ RACK_DEVICE_COUNT = """ {{ value }} """ -RACKRESERVATION_ACTIONS = """ - - - -{% if perms.dcim.change_rackreservation %} - -{% endif %} -""" - -MANUFACTURER_ACTIONS = """ - - - -{% if perms.dcim.change_manufacturer %} - -{% endif %} -""" - -DEVICEROLE_ACTIONS = """ - - - -{% if perms.dcim.change_devicerole %} - -{% endif %} -""" - DEVICEROLE_DEVICE_COUNT = """ {{ value }} """ @@ -119,15 +68,6 @@ PLATFORM_VM_COUNT = """ {{ value }} """ -PLATFORM_ACTIONS = """ - - - -{% if perms.dcim.change_platform %} - -{% endif %} -""" - STATUS_LABEL = """ {{ record.get_status_display }} """ @@ -198,11 +138,7 @@ class RegionTable(BaseTable): site_count = tables.Column( verbose_name='Sites' ) - actions = tables.TemplateColumn( - template_code=REGION_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Region) class Meta(BaseTable.Meta): model = Region @@ -260,10 +196,9 @@ class RackGroupTable(BaseTable): rack_count = tables.Column( verbose_name='Racks' ) - actions = tables.TemplateColumn( - template_code=RACKGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' + actions = ButtonsColumn( + model=RackGroup, + prepend_template=RACKGROUP_ELEVATIONS ) class Meta(BaseTable.Meta): @@ -280,11 +215,7 @@ class RackRoleTable(BaseTable): pk = ToggleColumn() rack_count = tables.Column(verbose_name='Racks') color = tables.TemplateColumn(COLOR_LABEL) - actions = tables.TemplateColumn( - template_code=RACKROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(RackRole) class Meta(BaseTable.Meta): model = RackRole @@ -386,11 +317,7 @@ class RackReservationTable(BaseTable): tags = TagColumn( url_name='dcim:rackreservation_list' ) - actions = tables.TemplateColumn( - template_code=RACKRESERVATION_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(RackReservation) class Meta(BaseTable.Meta): model = RackReservation @@ -420,11 +347,7 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - actions = tables.TemplateColumn( - template_code=MANUFACTURER_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Manufacturer, pk_field='slug') class Meta(BaseTable.Meta): model = Manufacturer @@ -609,11 +532,8 @@ class DeviceRoleTable(BaseTable): template_code=COLOR_LABEL, verbose_name='Label' ) - actions = tables.TemplateColumn( - template_code=DEVICEROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + vm_role = BooleanColumn() + actions = ButtonsColumn(DeviceRole, pk_field='slug') class Meta(BaseTable.Meta): model = DeviceRole @@ -639,11 +559,7 @@ class PlatformTable(BaseTable): orderable=False, verbose_name='VMs' ) - actions = tables.TemplateColumn( - template_code=PLATFORM_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Platform, pk_field='slug') class Meta(BaseTable.Meta): model = Platform diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 7a78d4b19..79a529059 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,21 +1,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ToggleColumn from .models import ConfigContext, ObjectChange, Tag, TaggedItem -TAG_ACTIONS = """ - - - -{% if perms.taggit.change_tag %} - -{% endif %} -{% if perms.taggit.delete_tag %} - -{% endif %} -""" - TAGGED_ITEM = """ {% if value.get_absolute_url %} {{ value }} @@ -68,12 +56,8 @@ class TagTable(BaseTable): viewname='extras:tag', args=[Accessor('slug')] ) - actions = tables.TemplateColumn( - template_code=TAG_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) color = ColorColumn() + actions = ButtonsColumn(Tag, pk_field='slug') class Meta(BaseTable.Meta): model = Tag diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index f1855327b..72ac3eb45 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """ @@ -25,15 +25,6 @@ RIR_UTILIZATION = """
    """ -RIR_ACTIONS = """ - - - -{% if perms.ipam.change_rir %} - -{% endif %} -""" - UTILIZATION_GRAPH = """ {% load helpers %} {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %} @@ -47,15 +38,6 @@ ROLE_VLAN_COUNT = """ {{ value }} """ -ROLE_ACTIONS = """ - - - -{% if perms.ipam.change_role %} - -{% endif %} -""" - PREFIX_LINK = """ {% if record.has_children %} @@ -136,10 +118,7 @@ VLAN_ROLE_LINK = """ {% endif %} """ -VLANGROUP_ACTIONS = """ - - - +VLANGROUP_ADD_VLAN = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -147,9 +126,6 @@ VLANGROUP_ACTIONS = """ {% endif %} {% endwith %} -{% if perms.ipam.change_vlangroup %} - -{% endif %} """ VLAN_MEMBER_UNTAGGED = """ @@ -214,11 +190,7 @@ class RIRTable(BaseTable): aggregate_count = tables.Column( verbose_name='Aggregates' ) - actions = tables.TemplateColumn( - template_code=RIR_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(RIR, pk_field='slug') class Meta(BaseTable.Meta): model = RIR @@ -322,11 +294,7 @@ class RoleTable(BaseTable): orderable=False, verbose_name='VLANs' ) - actions = tables.TemplateColumn( - template_code=ROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(Role, pk_field='slug') class Meta(BaseTable.Meta): model = Role @@ -516,10 +484,9 @@ class VLANGroupTable(BaseTable): vlan_count = tables.Column( verbose_name='VLANs' ) - actions = tables.TemplateColumn( - template_code=VLANGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' + actions = ButtonsColumn( + model=VLANGroup, + prepend_template=VLANGROUP_ADD_VLAN ) class Meta(BaseTable.Meta): diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index f92c9216b..f773a278f 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,17 +1,8 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import SecretRole, Secret -SECRETROLE_ACTIONS = """ - - - -{% if perms.secrets.change_secretrole %} - -{% endif %} -""" - # # Secret roles @@ -23,11 +14,7 @@ class SecretRoleTable(BaseTable): secret_count = tables.Column( verbose_name='Secrets' ) - actions = tables.TemplateColumn( - template_code=SECRETROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(SecretRole, pk_field='slug') class Meta(BaseTable.Meta): model = SecretRole diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 147a20707..dc96b839c 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import Tenant, TenantGroup MPTT_LINK = """ @@ -13,15 +13,6 @@ MPTT_LINK = """ """ -TENANTGROUP_ACTIONS = """ - - - -{% if perms.tenancy.change_tenantgroup %} - -{% endif %} -""" - COL_TENANT = """ {% if record.tenant %} {{ record.tenant }} @@ -44,11 +35,7 @@ class TenantGroupTable(BaseTable): tenant_count = tables.Column( verbose_name='Tenants' ) - actions = tables.TemplateColumn( - template_code=TENANTGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(TenantGroup, pk_field='slug') class Meta(BaseTable.Meta): model = TenantGroup diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 5e277e633..ec3d5dff5 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -123,6 +123,49 @@ class BooleanColumn(tables.Column): return mark_safe(rendered) +class ButtonsColumn(tables.TemplateColumn): + """ + Render edit, delete, and changelog buttons for an object. + + :param model: Model class to use for calculating URL view names + :param prepend_content: Additional template content to render in the column (optional) + """ + attrs = {'td': {'class': 'text-right text-nowrap noprint'}} + # Note that braces are escaped to allow for string formatting prior to template rendering + template_code = """ + + + + {{% if perms.{app_label}.change_{model_name} %}} + + + + {{% endif %}} + {{% if perms.{app_label}.delete_{model_name} %}} + + + + {{% endif %}} + """ + + def __init__(self, model, *args, pk_field='pk', prepend_template=None, **kwargs): + if prepend_template: + prepend_template = prepend_template.replace('{', '{{') + prepend_template = prepend_template.replace('}', '}}') + self.template_code = prepend_template + self.template_code + + template_code = self.template_code.format( + app_label=model._meta.app_label, + model_name=model._meta.model_name, + pk_field=pk_field + ) + + super().__init__(template_code=template_code, *args, **kwargs) + + def header(self): + return '' + + class ColorColumn(tables.Column): """ Display a color (#RRGGBB). diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index de319361c..d53572583 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -2,27 +2,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, ColoredLabelColumn, TagColumn, ToggleColumn from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface -CLUSTERTYPE_ACTIONS = """ - - - -{% if perms.virtualization.change_clustertype %} - -{% endif %} -""" - -CLUSTERGROUP_ACTIONS = """ - - - -{% if perms.virtualization.change_clustergroup %} - -{% endif %} -""" - VIRTUALMACHINE_STATUS = """ {{ record.get_status_display }} """ @@ -44,11 +26,7 @@ class ClusterTypeTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) - actions = tables.TemplateColumn( - template_code=CLUSTERTYPE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(ClusterType, pk_field='slug') class Meta(BaseTable.Meta): model = ClusterType @@ -66,11 +44,7 @@ class ClusterGroupTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) - actions = tables.TemplateColumn( - template_code=CLUSTERGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(ClusterGroup, pk_field='slug') class Meta(BaseTable.Meta): model = ClusterGroup From 57b73c485fa213b8437db7340131822fca4eed26 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 14:21:51 -0400 Subject: [PATCH 130/137] #4416: Remove individual view for extras.Tag --- netbox/extras/models/tags.py | 3 --- netbox/extras/tables.py | 4 ---- netbox/extras/tests/test_views.py | 2 +- netbox/extras/urls.py | 1 - netbox/extras/views.py | 27 --------------------------- 5 files changed, 1 insertion(+), 36 deletions(-) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 9bb90f21e..39ac86073 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -26,9 +26,6 @@ class Tag(TagBase, ChangeLoggedModel): csv_headers = ['name', 'slug', 'color', 'description'] - def get_absolute_url(self): - return reverse('extras:tag', args=[self.slug]) - def slugify(self, tag, i=None): # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) slug = slugify(tag, allow_unicode=True) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 79a529059..9d49988e7 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -52,10 +52,6 @@ OBJECTCHANGE_REQUEST_ID = """ class TagTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn( - viewname='extras:tag', - args=[Accessor('slug')] - ) color = ColorColumn() actions = ButtonsColumn(Tag, pk_field='slug') diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index b3abf5b22..f14f02e43 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -10,7 +10,7 @@ from extras.models import ConfigContext, ObjectChange, Tag from utilities.testing import ViewTestCases, TestCase -class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): +class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Tag @classmethod diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 3007e6524..95c0ae8d9 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -13,7 +13,6 @@ urlpatterns = [ path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path('tags//', views.TagView.as_view(), name='tag'), path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 879ca89d0..921b05792 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -40,33 +40,6 @@ class TagListView(ObjectListView): table = tables.TagTable -class TagView(ObjectView): - queryset = Tag.objects.all() - - def get(self, request, slug): - - tag = get_object_or_404(self.queryset, slug=slug) - tagged_items = TaggedItem.objects.filter( - tag=tag - ).prefetch_related( - 'content_type', 'content_object' - ) - - # Generate a table of all items tagged with this Tag - items_table = tables.TaggedItemTable(tagged_items) - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) - } - RequestConfig(request, paginate).configure(items_table) - - return render(request, 'extras/tag.html', { - 'tag': tag, - 'items_count': tagged_items.count(), - 'items_table': items_table, - }) - - class TagEditView(ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm From 225b6c695839984a8246c574b42b3094f4347002 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 14:23:21 -0400 Subject: [PATCH 131/137] Fix collection of assigned IPs when editing a device --- netbox/dcim/forms.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index df2625e67..2b65940a5 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1843,20 +1843,22 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True) # Collect interface IPs - interface_ips = IPAddress.objects.prefetch_related('interface').filter( + interface_ips = IPAddress.objects.filter( address__family=family, - interface__in=interface_ids - ) + assigned_object_type=ContentType.objects.get_for_model(Interface), + assigned_object_id__in=interface_ids + ).prefetch_related('assigned_object') if interface_ips: - ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] + ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] ip_choices.append(('Interface IPs', ip_list)) # Collect NAT IPs nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( address__family=family, - nat_inside__interface__in=interface_ids - ) + nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface), + nat_inside__assigned_object_id__in=interface_ids + ).prefetch_related('assigned_object') if nat_ips: - ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] + ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips] ip_choices.append(('NAT IPs', ip_list)) self.fields['primary_ip{}'.format(family)].choices = ip_choices From 7e3e18faeabf419fe97c540e28103e4ceae503d4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 14:46:12 -0400 Subject: [PATCH 132/137] #4416: Add individual & changelog views for InventoryItem --- netbox/dcim/models/device_components.py | 2 +- netbox/dcim/tables.py | 37 +++----- netbox/dcim/tests/test_views.py | 11 +-- netbox/dcim/urls.py | 6 +- netbox/dcim/views.py | 98 ++++++++++---------- netbox/templates/dcim/inc/inventoryitem.html | 4 +- netbox/templates/dcim/inventoryitem.html | 73 +++++++++++++++ netbox/templates/inc/nav_menu.html | 18 ++-- 8 files changed, 155 insertions(+), 94 deletions(-) create mode 100644 netbox/templates/dcim/inventoryitem.html diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index d94e2484f..eaac1df7a 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1120,7 +1120,7 @@ class InventoryItem(ComponentModel): return self.name def get_absolute_url(self): - return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk}) + return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) def to_csv(self): return ( diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 871679fc7..f258df221 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -775,6 +775,20 @@ class DeviceBayTable(DeviceComponentTable): default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description') +class InventoryItemTable(DeviceComponentTable): + manufacturer = tables.Column( + linkify=True + ) + discovered = BooleanColumn() + + class Meta(DeviceComponentTable.Meta): + model = InventoryItem + fields = ( + 'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered' + ) + default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag') + + # # Cables # @@ -917,29 +931,6 @@ class InterfaceConnectionTable(BaseTable): ) -# -# InventoryItems -# - -class InventoryItemTable(BaseTable): - pk = ToggleColumn() - device = tables.LinkColumn( - viewname='dcim:device_inventory', - args=[Accessor('device.pk')] - ) - manufacturer = tables.Column( - accessor=Accessor('manufacturer') - ) - discovered = BooleanColumn() - - class Meta(BaseTable.Meta): - model = InventoryItem - fields = ( - 'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered' - ) - default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag') - - # # Virtual chassis # diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b3994ea8c..799d186b2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1419,16 +1419,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): ) -# TODO: Convert to DeviceComponentViewTestCase? -class InventoryItemTestCase( - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.ListObjectsViewTestCase, - ViewTestCases.BulkCreateObjectsViewTestCase, - ViewTestCases.BulkImportObjectsViewTestCase, - ViewTestCases.BulkEditObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem @classmethod diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 4d5964ee1..53d65502c 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -5,8 +5,8 @@ from ipam.views import ServiceEditView from . import views from .models import ( Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface, - Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, - RearPort, Region, Site, VirtualChassis, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, + RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) app_name = 'dcim' @@ -322,8 +322,10 @@ urlpatterns = [ path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), # TODO: Bulk rename view for InventoryItems path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), + path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'), path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), + path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}), # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3eebbabae..054d3de34 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1716,6 +1716,57 @@ class DeviceBayBulkDeleteView(BulkDeleteView): table = tables.DeviceBayTable +# +# Inventory items +# + +class InventoryItemListView(ObjectListView): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + filterset = filters.InventoryItemFilterSet + filterset_form = forms.InventoryItemFilterForm + table = tables.InventoryItemTable + action_buttons = ('import', 'export') + + +class InventoryItemView(ObjectView): + queryset = InventoryItem.objects.all() + + +class InventoryItemEditView(ObjectEditView): + queryset = InventoryItem.objects.all() + model_form = forms.InventoryItemForm + + +class InventoryItemCreateView(ComponentCreateView): + queryset = InventoryItem.objects.all() + form = forms.InventoryItemCreateForm + model_form = forms.InventoryItemForm + template_name = 'dcim/device_component_add.html' + + +class InventoryItemDeleteView(ObjectDeleteView): + queryset = InventoryItem.objects.all() + + +class InventoryItemBulkImportView(BulkImportView): + queryset = InventoryItem.objects.all() + model_form = forms.InventoryItemCSVForm + table = tables.InventoryItemTable + + +class InventoryItemBulkEditView(BulkEditView): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + filterset = filters.InventoryItemFilterSet + table = tables.InventoryItemTable + form = forms.InventoryItemBulkEditForm + + +class InventoryItemBulkDeleteView(BulkDeleteView): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + table = tables.InventoryItemTable + template_name = 'dcim/inventoryitem_bulk_delete.html' + + # # Bulk Device component creation # @@ -2048,53 +2099,6 @@ class InterfaceConnectionsListView(ObjectListView): return '\n'.join(csv_data) -# -# Inventory items -# - -class InventoryItemListView(ObjectListView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') - filterset = filters.InventoryItemFilterSet - filterset_form = forms.InventoryItemFilterForm - table = tables.InventoryItemTable - action_buttons = ('import', 'export') - - -class InventoryItemEditView(ObjectEditView): - queryset = InventoryItem.objects.all() - model_form = forms.InventoryItemForm - - -class InventoryItemCreateView(ComponentCreateView): - queryset = InventoryItem.objects.all() - form = forms.InventoryItemCreateForm - model_form = forms.InventoryItemForm - template_name = 'dcim/device_component_add.html' - - -class InventoryItemDeleteView(ObjectDeleteView): - queryset = InventoryItem.objects.all() - - -class InventoryItemBulkImportView(BulkImportView): - queryset = InventoryItem.objects.all() - model_form = forms.InventoryItemCSVForm - table = tables.InventoryItemTable - - -class InventoryItemBulkEditView(BulkEditView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') - filterset = filters.InventoryItemFilterSet - table = tables.InventoryItemTable - form = forms.InventoryItemBulkEditForm - - -class InventoryItemBulkDeleteView(BulkDeleteView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') - table = tables.InventoryItemTable - template_name = 'dcim/inventoryitem_bulk_delete.html' - - # # Virtual chassis # diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 56ccfeace..d3a78afd4 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -1,5 +1,7 @@ - {{ item }} + + {{ item }} + {% if not item.discovered %}{% endif %} {{ item.manufacturer|default:"" }} {{ item.part_id }} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html new file mode 100644 index 000000000..6e12d9f33 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem.html @@ -0,0 +1,73 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Inventory Item +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Parent Item + {% if instance.parent %} + {{ instance.parent }} + {% else %} + + {% endif %} +
    Name{{ instance.name }}
    Manufacturer + {% if instance.manufacturer %} + {{ instance.manufacturer }} + {% else %} + + {% endif %} +
    Part ID{{ instance.part_id|placeholder }}
    Serial{{ instance.serial|placeholder }}
    Asset Tag{{ instance.asset_tag|placeholder }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index f22baf7cc..23808bc04 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -173,16 +173,6 @@ Manufacturers
  • - - - {% if perms.dcim.add_inventoryitem %} -
    - -
    - {% endif %} - Inventory Items - -
  • {% if perms.dcim.add_cable %} @@ -267,6 +257,14 @@ {% endif %} Device Bays + + {% if perms.dcim.add_inventoryitem %} +
    + +
    + {% endif %} + Inventory Items +
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitem %} +
  • Inventory Items
  • + {% endif %}
    {% endif %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index f236a0550..b1cd32eea 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -14,6 +14,7 @@ {% if perms.dcim.add_interface %}
  • Interfaces
  • {% endif %} {% if perms.dcim.add_rearport %}
  • Rear Ports
  • {% endif %} {% if perms.dcim.add_devicebay %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitem %}
  • Inventory Items
  • {% endif %}
    {% endif %} From 06ae424b80f2fca5b6940f55c63084b0fede748c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 15:12:05 -0400 Subject: [PATCH 134/137] #4416: Add bulk rename view for InventoryItem --- netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 4 + netbox/templates/dcim/device_inventory.html | 108 +++++++++---------- netbox/templates/dcim/inc/inventoryitem.html | 33 ++++-- 4 files changed, 86 insertions(+), 61 deletions(-) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 831d40402..7af91f0ae 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -320,7 +320,7 @@ urlpatterns = [ path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'), path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), - # TODO: Bulk rename view for InventoryItems + path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'), path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'), path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4456e408d..101c3ea6e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1761,6 +1761,10 @@ class InventoryItemBulkEditView(BulkEditView): form = forms.InventoryItemBulkEditForm +class InventoryItemBulkRenameView(BulkRenameView): + queryset = InventoryItem.objects.all() + + class InventoryItemBulkDeleteView(BulkDeleteView): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') table = tables.InventoryItemTable diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 1a7e5d793..69afbb6a1 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -5,61 +5,61 @@ {% block content %}
    -
    -
    -
    - Chassis -
    - - - - - - - - - - - - - -
    Model{{ device.device_type.display_name }}
    Serial Number{{ device.serial|placeholder }}
    Asset Tag{{ device.asset_tag|placeholder }}
    -
    -
    -
    -
    -
    - Hardware -
    - - - - - - - - - - - - - - - {% for item in inventory_items %} - {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} - {% include template_name %} - {% endwith %} - {% endfor %} - -
    NameManufacturerPart IDSerial NumberAsset TagDescription
    - {% if perms.dcim.add_inventoryitem %} -
    {% endblock %} diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index d3a78afd4..1b103893f 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -1,13 +1,34 @@ +{% load helpers %} + + {# Checkbox #} + {% if perms.dcim.change_inventoryitem or perms.dcim.delete_inventoryitem %} + + + + {% endif %} + {{ item }} - {% if not item.discovered %}{% endif %} - {{ item.manufacturer|default:"" }} - {{ item.part_id }} - {{ item.serial }} - {{ item.asset_tag|default:"" }} - {{ item.description }} + + {% if item.manufacturer %} + {{ item.manufacturer }} + {% else %} + + {% endif %} + + {{ item.part_id|placeholder }} + {{ item.serial|placeholder }} + {{ item.asset_tag|placeholder }} + + {% if item.discovered %} + + {% else %} + + {% endif %} + + {{ item.description|placeholder }} {% if perms.dcim.change_inventoryitem %} From fa0c7a76cb2ab461efc12774565a8c3a0fd0abcb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 1 Jul 2020 15:14:06 -0400 Subject: [PATCH 135/137] Fix permission evaluation for add console/power port buttons --- netbox/templates/dcim/device.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 710288da6..01f125db4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -358,7 +358,7 @@ Delete {% endif %} - {% if console_ports and perms.dcim.change_consoleport %} + {% if console_ports and perms.dcim.add_consoleport %}
    Add console port @@ -398,7 +398,7 @@ Delete {% endif %} - {% if power_ports and perms.dcim.change_powerport %} + {% if power_ports and perms.dcim.add_powerport %}
    Add power port From 9f614452b48caa5789b752219b7d7e134c09be98 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 2 Jul 2020 09:37:20 -0400 Subject: [PATCH 136/137] 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 137/137] 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()