From 7c09eb298ddf015eedf0454bd21b551ad1618bd2 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 1 Aug 2023 13:01:36 -0500 Subject: [PATCH 01/22] Catch AssertionError's in signals. Handle accordingly --- netbox/dcim/models/cables.py | 4 ++++ netbox/dcim/signals.py | 6 +++++- netbox/dcim/utils.py | 10 +++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index af69c440e54..dbf6f770bb4 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -478,8 +478,11 @@ def from_origin(cls, terminations): Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be of the same type and must belong to the same parent object. """ + import logging from circuits.models import CircuitTermination + logger = logging.getLogger('netbox.dcim.cablepath') + if not terminations: return None @@ -558,6 +561,7 @@ def from_origin(cls, terminations): pk__in=[t.rear_port_id for t in remote_terminations] ) if len(rear_ports) > 1: + logger.warning(f'All rear-port positions do not match. Cannot continue path trace.') assert all(rp.positions == 1 for rp in rear_ports) elif rear_ports[0].positions > 1: position_stack.append([fp.rear_port_position for fp in remote_terminations]) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index a51872719a7..4aae71583fa 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -95,7 +95,11 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): if not nodes: continue if isinstance(nodes[0], PathEndpoint): - create_cablepath(nodes) + try: + create_cablepath(nodes) + except AssertionError: + # This is likely an unsupported path. Catch the assertion error and don't save the path + logger.error(f'Unsupported path from nodes: {[node.name for node in nodes].join(",")}') else: rebuild_paths(nodes) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index eadd2da9694..5740ddcc7b6 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -49,12 +49,20 @@ def rebuild_paths(terminations): """ Rebuild all CablePaths which traverse the specified nodes. """ + import logging from dcim.models import CablePath + logger = logging.getLogger('netbox.dcim.cable') + for obj in terminations: cable_paths = CablePath.objects.filter(_nodes__contains=obj) with transaction.atomic(): for cp in cable_paths: cp.delete() - create_cablepath(cp.origins) + try: + create_cablepath(cp.origins) + except AssertionError: + # This is likely an unsupported path. Catch the assertion error and don't save the path + logger.error(f'Unsupported path from cable path: {cp._nodes}') + pass From 32c4f3c8a6ab60727df10cbd36fb484bfb384045 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 3 Aug 2023 22:07:46 -0500 Subject: [PATCH 02/22] Alter cable logic to handle certain additional path types. --- netbox/dcim/models/cables.py | 77 +++++++++++++++++++++--------------- netbox/dcim/signals.py | 6 +-- netbox/dcim/utils.py | 7 +--- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index dbf6f770bb4..d397e92ac9a 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -503,7 +503,9 @@ def from_origin(cls, terminations): # Check for a split path (e.g. rear port fanning out to multiple front ports with # different cables attached) - if len(set(t.link for t in terminations)) > 1: + if len(set(t.link for t in terminations)) > 1 and ( + position_stack and len(terminations) != len(position_stack[-1]) + ): is_split = True break @@ -512,46 +514,54 @@ def from_origin(cls, terminations): object_to_path_node(t) for t in terminations ]) - # Step 2: Determine the attached link (Cable or WirelessLink), if any - link = terminations[0].link - if link is None and len(path) == 1: + # Step 2: Determine the attached links (Cable or WirelessLink), if any + links = [termination.link for termination in terminations if termination.link is not None] + if len(links) == 0 and len(path) == 1: # If this is the start of the path and no link exists, return None return None - elif link is None: + elif len(links) == 0: # Otherwise, halt the trace if no link exists break - assert type(link) in (Cable, WirelessLink) + assert all(type(link) in (Cable, WirelessLink) for link in links) - # Step 3: Record the link and update path status if not "connected" - path.append([object_to_path_node(link)]) - if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED: + # Step 3: Record the links + path.append([object_to_path_node(link) for link in links]) + + # Step 4: Update the path status if a link is not connected + links_status = [ + link.status for link in links if hasattr(link, 'status') and + link.status != LinkStatusChoices.STATUS_CONNECTED + ] + if len(links_status) > 0 and len(links) != len(links_status): is_active = False - # Step 4: Determine the far-end terminations - if isinstance(link, Cable): + # Step 5: Determine the far-end terminations + if isinstance(links[0], Cable): termination_type = ContentType.objects.get_for_model(terminations[0]) local_cable_terminations = CableTermination.objects.filter( termination_type=termination_type, termination_id__in=[t.pk for t in terminations] ) - # Terminations must all belong to same end of Cable - local_cable_end = local_cable_terminations[0].cable_end - assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:]) - remote_cable_terminations = CableTermination.objects.filter( - cable=link, - cable_end='A' if local_cable_end == 'B' else 'B' - ) + + q_filter = Q() + for lct in local_cable_terminations: + q_filter |= Q(cable=lct.cable, cable_end='A' if lct.cable_end == 'B' else 'B') + + assert q_filter is not Q() + remote_cable_terminations = CableTermination.objects.filter(q_filter) remote_terminations = [ct.termination for ct in remote_cable_terminations] else: # WirelessLink - remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a] + remote_terminations = [ + link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links + ] - # Step 5: Record the far-end termination object(s) + # Step 6: Record the far-end termination object(s) path.append([ object_to_path_node(t) for t in remote_terminations if t is not None ]) - # Step 6: Determine the "next hop" terminations, if applicable + # Step 7: Determine the "next hop" terminations, if applicable if not remote_terminations: break @@ -560,26 +570,31 @@ def from_origin(cls, terminations): rear_ports = RearPort.objects.filter( pk__in=[t.rear_port_id for t in remote_terminations] ) - if len(rear_ports) > 1: - logger.warning(f'All rear-port positions do not match. Cannot continue path trace.') - assert all(rp.positions == 1 for rp in rear_ports) - elif rear_ports[0].positions > 1: + if len(rear_ports) > 1 or rear_ports[0].positions > 1: position_stack.append([fp.rear_port_position for fp in remote_terminations]) terminations = rear_ports elif isinstance(remote_terminations[0], RearPort): - - if len(remote_terminations) > 1 or remote_terminations[0].positions == 1: - front_ports = FrontPort.objects.filter( - rear_port_id__in=[rp.pk for rp in remote_terminations], - rear_port_position=1 - ) + if len(remote_terminations) > 1 and position_stack: + positions = position_stack.pop() + assert len(remote_terminations) == len(positions) + q_filter = Q() + for rt in remote_terminations: + position = positions.pop() + q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) + assert q_filter is not Q() + front_ports = FrontPort.objects.filter(q_filter) elif position_stack: front_ports = FrontPort.objects.filter( rear_port_id=remote_terminations[0].pk, rear_port_position__in=position_stack.pop() ) + elif len(remote_terminations) == 1: + front_ports = FrontPort.objects.filter( + rear_port_id__in=[rp.pk for rp in remote_terminations], + rear_port_position=1 + ) else: # No position indicated: path has split, so we stop at the RearPorts is_split = True diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 4aae71583fa..a51872719a7 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -95,11 +95,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): if not nodes: continue if isinstance(nodes[0], PathEndpoint): - try: - create_cablepath(nodes) - except AssertionError: - # This is likely an unsupported path. Catch the assertion error and don't save the path - logger.error(f'Unsupported path from nodes: {[node.name for node in nodes].join(",")}') + create_cablepath(nodes) else: rebuild_paths(nodes) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 5740ddcc7b6..84b2703195c 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -60,9 +60,4 @@ def rebuild_paths(terminations): with transaction.atomic(): for cp in cable_paths: cp.delete() - try: - create_cablepath(cp.origins) - except AssertionError: - # This is likely an unsupported path. Catch the assertion error and don't save the path - logger.error(f'Unsupported path from cable path: {cp._nodes}') - pass + create_cablepath(cp.origins) From a797995ebb72b0e35e41b65fe0445d7f36de9daa Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 4 Aug 2023 23:54:45 -0500 Subject: [PATCH 03/22] Fix failures and add test --- netbox/dcim/models/cables.py | 24 ++++++---- netbox/dcim/tests/test_cablepaths.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index d397e92ac9a..b848e5fd2a2 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -19,7 +19,7 @@ from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters from wireless.models import WirelessLink -from .device_components import FrontPort, RearPort +from .device_components import FrontPort, RearPort, PathEndpoint __all__ = ( 'Cable', @@ -501,6 +501,10 @@ def from_origin(cls, terminations): # Terminations must all be of the same type assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + # All mid-span terminations must all be attached to the same device + if not isinstance(terminations[0], PathEndpoint): + assert all(t.device == terminations[0].device for t in terminations[1:]) + # Check for a split path (e.g. rear port fanning out to multiple front ports with # different cables attached) if len(set(t.link for t in terminations)) > 1 and ( @@ -525,7 +529,7 @@ def from_origin(cls, terminations): assert all(type(link) in (Cable, WirelessLink) for link in links) # Step 3: Record the links - path.append([object_to_path_node(link) for link in links]) + path.append([object_to_path_node(link) for link in list(set(links))]) # Step 4: Update the path status if a link is not connected links_status = [ @@ -576,7 +580,12 @@ def from_origin(cls, terminations): terminations = rear_ports elif isinstance(remote_terminations[0], RearPort): - if len(remote_terminations) > 1 and position_stack: + if len(remote_terminations) == 1 and remote_terminations[0].positions == 1: + front_ports = FrontPort.objects.filter( + rear_port_id__in=[rp.pk for rp in remote_terminations], + rear_port_position=1 + ) + elif len(remote_terminations) > 1 and position_stack: positions = position_stack.pop() assert len(remote_terminations) == len(positions) q_filter = Q() @@ -590,11 +599,6 @@ def from_origin(cls, terminations): rear_port_id=remote_terminations[0].pk, rear_port_position__in=position_stack.pop() ) - elif len(remote_terminations) == 1: - front_ports = FrontPort.objects.filter( - rear_port_id__in=[rp.pk for rp in remote_terminations], - rear_port_position=1 - ) else: # No position indicated: path has split, so we stop at the RearPorts is_split = True @@ -636,12 +640,14 @@ def from_origin(cls, terminations): is_complete = True break - return cls( + cablepath = cls( path=path, is_complete=is_complete, is_active=is_active, is_split=is_split ) + print(f'{cablepath}::{cablepath.path}:{is_complete}:{is_active}:{is_split}') + return cablepath def retrace(self): """ diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 3367a3efe22..a34db383d2a 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1695,6 +1695,78 @@ def test_218_interfaces_to_interfaces_via_multiposition_rear_ports(self): self.assertPathIsSet(interface3, path3) self.assertPathIsSet(interface4, path4) + def test_219_interface_to_interface_duplex_via_multiple_rearports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [FP3] [RP3] --C4-- [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3] + ) + cable1.save() + self.assertPathExists( + (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] From 7a15b2bce851f05bf10002435ce8c966188356b5 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 7 Aug 2023 10:33:10 -0500 Subject: [PATCH 04/22] More tests --- netbox/dcim/tests/test_cablepaths.py | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index a34db383d2a..f9f870b53c5 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1767,6 +1767,126 @@ def test_219_interface_to_interface_duplex_via_multiple_rearports(self): ) self.assertEqual(CablePath.objects.count(), 2) + def test_220_interfaces_to_interface_via_multiple_rearports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF3] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] + """ + # TODO: Not Implemented Yet + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + self.assertPathExists( + (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface1, cable1, (frontport1), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + cable5 = Cable( + a_terminations=[frontport3], + b_terminations=[interface3] + ) + cable5.save() + self.assertPathExists( + ( + (interface1, interface3), (cable1, cable3), (frontport1, frontport3), (rearport1, rearport3), + (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + def test_221_interfaces_to_interfaces_via_multiple_rearports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF3] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [IF4] + """ + # TODO: Not Implemented Yet + self.assertFalse(True) + + def test_222_interface_to_interface_duplex_via_multiple_rearports_with_modules(self): + """ + [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] + [[FP3] [RP3]] --C4-- [[RP4] [FP4]] + """ + # TODO: Not Implemented Yet + self.assertFalse(True) + + def test_223_interfaces_to_interface_duplex_via_multiple_rearports_with_modules(self): + """ + [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] + [IF3] --C5-- [[FP3] [RP3]] --C4-- [[RP4] [FP4]] + """ + # TODO: Not Implemented Yet + self.assertFalse(True) + + def test_224_interfaces_to_interfaces_duplex_via_multiple_rearports_with_modules(self): + """ + [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] + [IF3] --C5-- [[FP3] [RP3]] --C4-- [[RP4] [FP4]] --C6-- [IF4] + """ + # TODO: Not Implemented Yet + self.assertFalse(True) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] From 4cf5ac54de27ceb749990cc67b3b498899b347c8 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 7 Aug 2023 10:40:44 -0500 Subject: [PATCH 05/22] Remove not needed tests, add additional tests --- netbox/dcim/tests/test_cablepaths.py | 86 ++++++++-------------------- 1 file changed, 24 insertions(+), 62 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index f9f870b53c5..ad0705c88f3 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -24,6 +24,7 @@ def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module') device_role = DeviceRole.objects.create(name='Device Role', slug='device-role') cls.device = Device.objects.create(site=cls.site, device_type=device_type, device_role=device_role, name='Test Device') @@ -1767,30 +1768,37 @@ def test_219_interface_to_interface_duplex_via_multiple_rearports(self): ) self.assertEqual(CablePath.objects.count(), 2) - def test_220_interfaces_to_interface_via_multiple_rearports(self): + def test_220_interface_to_interface_duplex_via_multiple_rearports_with_modules(self): """ - [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] - [IF3] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] + [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] + [[FP3] [RP3]] --C4-- [[RP4] [FP4]] """ - # TODO: Not Implemented Yet + module_type = ModuleType.objects.get(model='Test Module') + module_bay1 = ModuleBay.objects.create(device=self.device, name='Module Bay 1', position='1') + module_bay2 = ModuleBay.objects.create(device=self.device, name='Module Bay 2', position='2') + module_bay3 = ModuleBay.objects.create(device=self.device, name='Module Bay 3', position='3') + module_bay4 = ModuleBay.objects.create(device=self.device, name='Module Bay 4', position='4') + module1 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay1) + module2 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay2) + module3 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay3) + module4 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay4) interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - interface3 = Interface.objects.create(device=self.device, name='Interface 3') - rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) - rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + rearport1 = RearPort.objects.create(device=self.device, module=module1, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, module=module2, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, module=module3, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, module=module4, name='Rear Port 4', positions=1) frontport1 = FrontPort.objects.create( - device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + device=self.device, name='Front Port 1', module=module1, rear_port=rearport1, rear_port_position=1 ) frontport2 = FrontPort.objects.create( - device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + device=self.device, name='Front Port 2', module=module2, rear_port=rearport2, rear_port_position=1 ) frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + device=self.device, name='Front Port 3', module=module3, rear_port=rearport3, rear_port_position=1 ) frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + device=self.device, name='Front Port 4', module=module4, rear_port=rearport4, rear_port_position=1 ) cable2 = Cable( @@ -1808,12 +1816,11 @@ def test_220_interfaces_to_interface_via_multiple_rearports(self): # Create cable1 cable1 = Cable( a_terminations=[interface1], - b_terminations=[frontport1] + b_terminations=[frontport1, frontport3] ) cable1.save() self.assertPathExists( - (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), - (rearport2, rearport4), (frontport2, frontport4)), + (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)), is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -1826,25 +1833,12 @@ def test_220_interfaces_to_interface_via_multiple_rearports(self): cable3.save() self.assertPathExists( ( - interface1, cable1, (frontport1), (rearport1, rearport3), (cable2, cable4), + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 ), is_complete=True, is_active=True ) - cable5 = Cable( - a_terminations=[frontport3], - b_terminations=[interface3] - ) - cable5.save() - self.assertPathExists( - ( - (interface1, interface3), (cable1, cable3), (frontport1, frontport3), (rearport1, rearport3), - (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 - ), - is_complete=True, - is_active=True - ) self.assertPathExists( ( interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), @@ -1855,38 +1849,6 @@ def test_220_interfaces_to_interface_via_multiple_rearports(self): ) self.assertEqual(CablePath.objects.count(), 2) - def test_221_interfaces_to_interfaces_via_multiple_rearports(self): - """ - [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] - [IF3] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [IF4] - """ - # TODO: Not Implemented Yet - self.assertFalse(True) - - def test_222_interface_to_interface_duplex_via_multiple_rearports_with_modules(self): - """ - [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] - [[FP3] [RP3]] --C4-- [[RP4] [FP4]] - """ - # TODO: Not Implemented Yet - self.assertFalse(True) - - def test_223_interfaces_to_interface_duplex_via_multiple_rearports_with_modules(self): - """ - [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] - [IF3] --C5-- [[FP3] [RP3]] --C4-- [[RP4] [FP4]] - """ - # TODO: Not Implemented Yet - self.assertFalse(True) - - def test_224_interfaces_to_interfaces_duplex_via_multiple_rearports_with_modules(self): - """ - [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] - [IF3] --C5-- [[FP3] [RP3]] --C4-- [[RP4] [FP4]] --C6-- [IF4] - """ - # TODO: Not Implemented Yet - self.assertFalse(True) - def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] From 4fb67dd6ae40e8eec6729162c23bb1cc5ea86e18 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 7 Aug 2023 12:03:52 -0500 Subject: [PATCH 06/22] Finish tests, correct some behaviour --- netbox/dcim/models/cables.py | 25 ++- netbox/dcim/tests/test_cablepaths.py | 225 +++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index b848e5fd2a2..a210ddd3a8d 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -528,8 +528,14 @@ def from_origin(cls, terminations): break assert all(type(link) in (Cable, WirelessLink) for link in links) + # Create set of links in path. Cannot use list(set()) as it does this in a non-deterministic manner + links_path = [] + for link in links: + if object_to_path_node(link) not in links_path: + links_path.append(object_to_path_node(link)) + # Step 3: Record the links - path.append([object_to_path_node(link) for link in list(set(links))]) + path.append(links_path) # Step 4: Update the path status if a link is not connected links_status = [ @@ -560,6 +566,12 @@ def from_origin(cls, terminations): link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links ] + # Remote Terminations must all be of the same type, otherwise return a split path + if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): + is_complete = False + is_split = True + break + # Step 6: Record the far-end termination object(s) path.append([ object_to_path_node(t) for t in remote_terminations if t is not None @@ -635,9 +647,14 @@ def from_origin(cls, terminations): terminations = [circuit_termination] - # Anything else marks the end of the path else: - is_complete = True + # Check for non-symmetric path + if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): + is_complete = True + else: + # Unsupported topology, mark as split and exit + is_complete = False + is_split = True break cablepath = cls( @@ -646,7 +663,7 @@ def from_origin(cls, terminations): is_active=is_active, is_split=is_split ) - print(f'{cablepath}::{cablepath.path}:{is_complete}:{is_active}:{is_split}') + return cablepath def retrace(self): diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index ad0705c88f3..206a0fe260e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1849,6 +1849,111 @@ def test_220_interface_to_interface_duplex_via_multiple_rearports_with_modules(s ) self.assertEqual(CablePath.objects.count(), 2) + def test_221_interface_to_interface_duplex_via_multiple_rearports_with_modules(self): + """ + [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] + [IF2] --C5-- [[FP3] [RP3]] --C4-- [[RP4] [FP4]] + """ + module_type = ModuleType.objects.get(model='Test Module') + module_bay1 = ModuleBay.objects.create(device=self.device, name='Module Bay 1', position='1') + module_bay2 = ModuleBay.objects.create(device=self.device, name='Module Bay 2', position='2') + module_bay3 = ModuleBay.objects.create(device=self.device, name='Module Bay 3', position='3') + module_bay4 = ModuleBay.objects.create(device=self.device, name='Module Bay 4', position='4') + module1 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay1) + module2 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay2) + module3 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay3) + module4 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay4) + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, module=module1, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, module=module2, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, module=module3, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, module=module4, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', module=module1, rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', module=module2, rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', module=module3, rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', module=module4, rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3] + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + print(CablePath.objects.all().count()) + for cp in CablePath.objects.all(): + print(f'{cp}::{cp.path}') + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3) + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] @@ -1999,3 +2104,123 @@ def test_303_remove_termination_from_existing_cable(self): is_complete=True, is_active=True ) + + def test_401_non_symmetric_paths(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/ + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) + rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + frontport5 = FrontPort.objects.create( + device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1 + ) + frontport6 = FrontPort.objects.create( + device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + cable6 = Cable( + a_terminations=[frontport4], + b_terminations=[frontport5], + label='C6' + ) + cable6.save() + cable7 = Cable( + a_terminations=[rearport5], + b_terminations=[rearport6], + label='C7' + ) + cable7.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1], + label='C1' + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3], + label='C5' + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport6], + b_terminations=[interface2], + label='C3' + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7), + (rearport1, rearport5), (frontport1, frontport5), (cable1, cable6) + ), + is_complete=False, + is_split=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) From b335b675caeeb30d3ae1d1a77a52417d95b4170b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 7 Aug 2023 12:45:26 -0500 Subject: [PATCH 07/22] Add check for mid-span device not allowed condition --- netbox/dcim/tests/test_cablepaths.py | 115 ++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 206a0fe260e..7b59ec90a6e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from dcim.models import * from dcim.svg import CableTraceSVG -from dcim.utils import object_to_path_node +from dcim.utils import object_to_path_node, path_node_to_object class CablePathTestCase(TestCase): @@ -55,6 +55,27 @@ def assertPathExists(self, nodes, **kwargs): return cablepath + def assertPathNotExists(self, nodes, **kwargs): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) + + :return: The matching CablePath (if any) + """ + path = [] + for step in nodes: + if type(step) in (list, tuple): + path.append([object_to_path_node(node) for node in step]) + else: + path.append([object_to_path_node(step)]) + + cablepath = CablePath.objects.filter(path=path, **kwargs).first() + self.assertIsNone(cablepath, msg='CablePath not found') + + return cablepath + def assertPathIsSet(self, origin, cablepath, msg=None): """ Assert that a specific CablePath instance is set as the path on the origin. @@ -2224,3 +2245,95 @@ def test_401_non_symmetric_paths(self): is_active=True ) self.assertEqual(CablePath.objects.count(), 3) + + def test_402_exclude_midspan_devices(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [FP3] [RP3] --C4-- [RP4] [FP4] / + """ + device = Device.objects.create( + site=self.site, + device_type=self.device.device_type, + device_role=self.device.device_role, + name='Test mid-span Device' + ) + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3], + label='C1' + ) + try: + cable1.save() + except AssertionError: + pass + self.assertPathNotExists( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4) + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2], + label='C3' + ) + + try: + cable3.save() + except AssertionError: + pass + self.assertPathNotExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport2), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertPathNotExists( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 0) From 3ca03d3d891e278472c286feaf42421d45db9893 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 7 Aug 2023 12:46:10 -0500 Subject: [PATCH 08/22] Remove excess import --- netbox/dcim/tests/test_cablepaths.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 7b59ec90a6e..f302e74015e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from dcim.models import * from dcim.svg import CableTraceSVG -from dcim.utils import object_to_path_node, path_node_to_object +from dcim.utils import object_to_path_node class CablePathTestCase(TestCase): From b32ecb50fb0a33faa73f994b1edaa8425ee7d76c Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 7 Aug 2023 12:47:31 -0500 Subject: [PATCH 09/22] Remove logging import --- netbox/dcim/models/cables.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index a210ddd3a8d..c01a26dccc6 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -478,11 +478,8 @@ def from_origin(cls, terminations): Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be of the same type and must belong to the same parent object. """ - import logging from circuits.models import CircuitTermination - logger = logging.getLogger('netbox.dcim.cablepath') - if not terminations: return None From d2a851b3bcf16556745ab95d55cee81799a0d77b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 7 Aug 2023 12:48:16 -0500 Subject: [PATCH 10/22] Remove logging import --- netbox/dcim/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 84b2703195c..eadd2da9694 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -49,11 +49,8 @@ def rebuild_paths(terminations): """ Rebuild all CablePaths which traverse the specified nodes. """ - import logging from dcim.models import CablePath - logger = logging.getLogger('netbox.dcim.cable') - for obj in terminations: cable_paths = CablePath.objects.filter(_nodes__contains=obj) From 880e79a1b59c9241c1c629485a1d6a1999ae3a6a Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 1 Sep 2023 00:27:27 -0500 Subject: [PATCH 11/22] Minor tweaks based on Arthur's feedback --- netbox/dcim/models/cables.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index d45361fdabd..eb2873673af 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -537,10 +537,10 @@ def from_origin(cls, terminations): # Step 2: Determine the attached links (Cable or WirelessLink), if any links = [termination.link for termination in terminations if termination.link is not None] - if len(links) == 0 and len(path) == 1: - # If this is the start of the path and no link exists, return None - return None - elif len(links) == 0: + if len(links) == 0: + if len(path) == 1: + # If this is the start of the path and no link exists, return None + return None # Otherwise, halt the trace if no link exists break assert all(type(link) in (Cable, WirelessLink) for link in links) @@ -559,7 +559,7 @@ def from_origin(cls, terminations): link.status for link in links if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED ] - if len(links_status) > 0 and len(links) != len(links_status): + if len(links_status) and len(links) != len(links_status): is_active = False # Step 5: Determine the far-end terminations @@ -614,15 +614,21 @@ def from_origin(cls, terminations): rear_port_id__in=[rp.pk for rp in remote_terminations], rear_port_position=1 ) + # Obtain the individual front ports based on the termination and all positions elif len(remote_terminations) > 1 and position_stack: positions = position_stack.pop() + + # Ensure we have a number of positions equal to the amount of remote terminations assert len(remote_terminations) == len(positions) + + # Get our front ports q_filter = Q() for rt in remote_terminations: position = positions.pop() q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) assert q_filter is not Q() front_ports = FrontPort.objects.filter(q_filter) + # Obtain the individual front ports based on the termination and position elif position_stack: front_ports = FrontPort.objects.filter( rear_port_id=remote_terminations[0].pk, @@ -674,15 +680,13 @@ def from_origin(cls, terminations): is_split = True break - cablepath = cls( + return cls( path=path, is_complete=is_complete, is_active=is_active, is_split=is_split ) - return cablepath - def retrace(self): """ Retrace the path from the currently-defined originating termination(s) From 837811d019c21361b583083740eb3823c9ddde2d Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 7 Sep 2023 14:41:06 -0500 Subject: [PATCH 12/22] Update netbox/dcim/tests/test_cablepaths.py Co-authored-by: Jeremy Stretch --- netbox/dcim/tests/test_cablepaths.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index e1ebc7d358a..7d18bfaa1ff 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1948,9 +1948,6 @@ def test_221_interface_to_interface_duplex_via_multiple_rearports_with_modules(s b_terminations=[interface2] ) cable3.save() - print(CablePath.objects.all().count()) - for cp in CablePath.objects.all(): - print(f'{cp}::{cp.path}') self.assertPathExists( ( interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), From e0f32926bb3d0e5f853ca133867f70a98b6f1088 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 7 Sep 2023 15:37:20 -0500 Subject: [PATCH 13/22] Update netbox/dcim/models/cables.py Co-authored-by: Jeremy Stretch --- netbox/dcim/models/cables.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index eb2873673af..c70b1d8889d 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -572,7 +572,8 @@ def from_origin(cls, terminations): q_filter = Q() for lct in local_cable_terminations: - q_filter |= Q(cable=lct.cable, cable_end='A' if lct.cable_end == 'B' else 'B') + cable_end = 'A' if lct.cable_end == 'B' else 'B' + q_filter |= Q(cable=lct.cable, cable_end=cable_end) assert q_filter is not Q() remote_cable_terminations = CableTermination.objects.filter(q_filter) From 1f4baa716ae033d72ff913e9dc77ed61944c62fa Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 7 Sep 2023 23:57:46 -0500 Subject: [PATCH 14/22] Changes to account for required SVG rendering changes and based on feedback --- netbox/dcim/models/cables.py | 16 +--- netbox/dcim/tests/test_cablepaths.py | 135 ++++++--------------------- 2 files changed, 31 insertions(+), 120 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c70b1d8889d..fb7a707834a 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -545,21 +545,12 @@ def from_origin(cls, terminations): break assert all(type(link) in (Cable, WirelessLink) for link in links) - # Create set of links in path. Cannot use list(set()) as it does this in a non-deterministic manner - links_path = [] - for link in links: - if object_to_path_node(link) not in links_path: - links_path.append(object_to_path_node(link)) - # Step 3: Record the links - path.append(links_path) + path.append([object_to_path_node(link) for link in links]) # Step 4: Update the path status if a link is not connected - links_status = [ - link.status for link in links if hasattr(link, 'status') and - link.status != LinkStatusChoices.STATUS_CONNECTED - ] - if len(links_status) and len(links) != len(links_status): + links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED] + if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]): is_active = False # Step 5: Determine the far-end terminations @@ -575,7 +566,6 @@ def from_origin(cls, terminations): cable_end = 'A' if lct.cable_end == 'B' else 'B' q_filter |= Q(cable=lct.cable, cable_end=cable_end) - assert q_filter is not Q() remote_cable_terminations = CableTermination.objects.filter(q_filter) remote_terminations = [ct.termination for ct in remote_cable_terminations] else: diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 7d18bfaa1ff..bad5157425a 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -15,6 +15,7 @@ class CablePathTestCase(TestCase): 1XX: Test direct connections between different endpoint types 2XX: Test different cable topologies 3XX: Test responses to changes in existing objects + 4XX: Test to exclude specific cable topologies """ @classmethod def setUpTestData(cls): @@ -34,9 +35,9 @@ def setUpTestData(cls): circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - def assertPathExists(self, nodes, **kwargs): + def _get_cablepath(self, nodes, **kwargs): """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. + Return a given cable path :param nodes: Iterable of steps, with each step being either a single node or a list of nodes :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) @@ -51,6 +52,18 @@ def assertPathExists(self, nodes, **kwargs): path.append([object_to_path_node(step)]) cablepath = CablePath.objects.filter(path=path, **kwargs).first() + return cablepath + + def assertPathExists(self, nodes, **kwargs): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) + + :return: The matching CablePath (if any) + """ + cablepath = self._get_cablepath(nodes, **kwargs) self.assertIsNotNone(cablepath, msg='CablePath not found') return cablepath @@ -64,14 +77,7 @@ def assertPathNotExists(self, nodes, **kwargs): :return: The matching CablePath (if any) """ - path = [] - for step in nodes: - if type(step) in (list, tuple): - path.append([object_to_path_node(node) for node in step]) - else: - path.append([object_to_path_node(step)]) - - cablepath = CablePath.objects.filter(path=path, **kwargs).first() + cablepath = self._get_cablepath(nodes, **kwargs) self.assertIsNone(cablepath, msg='CablePath not found') return cablepath @@ -327,7 +333,7 @@ def test_121_multi_interface_to_multi_interface(self): is_active=True ) path2 = self.assertPathExists( - ((interface3, interface4), cable1, (interface1, interface2)), + ((interface3, interface4), (cable1, cable1), (interface1, interface2)), is_complete=True, is_active=True ) @@ -1789,119 +1795,34 @@ def test_219_interface_to_interface_duplex_via_multiple_rearports(self): ) self.assertEqual(CablePath.objects.count(), 2) - def test_220_interface_to_interface_duplex_via_multiple_rearports_with_modules(self): - """ - [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] - [[FP3] [RP3]] --C4-- [[RP4] [FP4]] - """ - module_type = ModuleType.objects.get(model='Test Module') - module_bay1 = ModuleBay.objects.create(device=self.device, name='Module Bay 1', position='1') - module_bay2 = ModuleBay.objects.create(device=self.device, name='Module Bay 2', position='2') - module_bay3 = ModuleBay.objects.create(device=self.device, name='Module Bay 3', position='3') - module_bay4 = ModuleBay.objects.create(device=self.device, name='Module Bay 4', position='4') - module1 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay1) - module2 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay2) - module3 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay3) - module4 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay4) - interface1 = Interface.objects.create(device=self.device, name='Interface 1') - interface2 = Interface.objects.create(device=self.device, name='Interface 2') - rearport1 = RearPort.objects.create(device=self.device, module=module1, name='Rear Port 1', positions=1) - rearport2 = RearPort.objects.create(device=self.device, module=module2, name='Rear Port 2', positions=1) - rearport3 = RearPort.objects.create(device=self.device, module=module3, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, module=module4, name='Rear Port 4', positions=1) - frontport1 = FrontPort.objects.create( - device=self.device, name='Front Port 1', module=module1, rear_port=rearport1, rear_port_position=1 - ) - frontport2 = FrontPort.objects.create( - device=self.device, name='Front Port 2', module=module2, rear_port=rearport2, rear_port_position=1 - ) - frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', module=module3, rear_port=rearport3, rear_port_position=1 - ) - frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', module=module4, rear_port=rearport4, rear_port_position=1 - ) - - cable2 = Cable( - a_terminations=[rearport1], - b_terminations=[rearport2] - ) - cable2.save() - cable4 = Cable( - a_terminations=[rearport3], - b_terminations=[rearport4] - ) - cable4.save() - self.assertEqual(CablePath.objects.count(), 0) - - # Create cable1 - cable1 = Cable( - a_terminations=[interface1], - b_terminations=[frontport1, frontport3] - ) - cable1.save() - self.assertPathExists( - (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)), - is_complete=False - ) - self.assertEqual(CablePath.objects.count(), 1) - - # Create cable 3 - cable3 = Cable( - a_terminations=[frontport2, frontport4], - b_terminations=[interface2] - ) - cable3.save() - self.assertPathExists( - ( - interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), - (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 - ), - is_complete=True, - is_active=True - ) - self.assertPathExists( - ( - interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), - (rearport1, rearport3), (frontport1, frontport3), cable1, interface1 - ), - is_complete=True, - is_active=True - ) - self.assertEqual(CablePath.objects.count(), 2) - - def test_221_interface_to_interface_duplex_via_multiple_rearports_with_modules(self): + def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self): """ - [IF1] --C1-- [[FP1] [RP1]] --C2-- [[RP2] [FP2]] --C3-- [IF2] - [IF2] --C5-- [[FP3] [RP3]] --C4-- [[RP4] [FP4]] + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] """ module_type = ModuleType.objects.get(model='Test Module') module_bay1 = ModuleBay.objects.create(device=self.device, name='Module Bay 1', position='1') module_bay2 = ModuleBay.objects.create(device=self.device, name='Module Bay 2', position='2') module_bay3 = ModuleBay.objects.create(device=self.device, name='Module Bay 3', position='3') module_bay4 = ModuleBay.objects.create(device=self.device, name='Module Bay 4', position='4') - module1 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay1) - module2 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay2) - module3 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay3) - module4 = Module.objects.create(device=self.device, module_type=module_type, module_bay=module_bay4) interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface3 = Interface.objects.create(device=self.device, name='Interface 3') - rearport1 = RearPort.objects.create(device=self.device, module=module1, name='Rear Port 1', positions=1) - rearport2 = RearPort.objects.create(device=self.device, module=module2, name='Rear Port 2', positions=1) - rearport3 = RearPort.objects.create(device=self.device, module=module3, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, module=module4, name='Rear Port 4', positions=1) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) frontport1 = FrontPort.objects.create( - device=self.device, name='Front Port 1', module=module1, rear_port=rearport1, rear_port_position=1 + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 ) frontport2 = FrontPort.objects.create( - device=self.device, name='Front Port 2', module=module2, rear_port=rearport2, rear_port_position=1 + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 ) frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', module=module3, rear_port=rearport3, rear_port_position=1 + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 ) frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', module=module4, rear_port=rearport4, rear_port_position=1 + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 ) cable2 = Cable( From de216aadf77b4300810e3315ad5fad7d605d8753 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 Sep 2023 10:01:57 -0500 Subject: [PATCH 15/22] More tweaks for cable path checking --- netbox/dcim/models/cables.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index fb7a707834a..21f1e946651 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -545,15 +545,21 @@ def from_origin(cls, terminations): break assert all(type(link) in (Cable, WirelessLink) for link in links) - # Step 3: Record the links + # Step 3: Record asymmetric paths as split + not_connected_terminations = [termination.link for termination in terminations if termination.link is None] + if len(not_connected_terminations) > 0: + is_complete = False + is_split = True + + # Step 4: Record the links path.append([object_to_path_node(link) for link in links]) - # Step 4: Update the path status if a link is not connected + # Step 5: Update the path status if a link is not connected links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED] if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]): is_active = False - # Step 5: Determine the far-end terminations + # Step 6: Determine the far-end terminations if isinstance(links[0], Cable): termination_type = ContentType.objects.get_for_model(terminations[0]) local_cable_terminations = CableTermination.objects.filter( @@ -580,12 +586,12 @@ def from_origin(cls, terminations): is_split = True break - # Step 6: Record the far-end termination object(s) + # Step 7: Record the far-end termination object(s) path.append([ object_to_path_node(t) for t in remote_terminations if t is not None ]) - # Step 7: Determine the "next hop" terminations, if applicable + # Step 8: Determine the "next hop" terminations, if applicable if not remote_terminations: break @@ -665,6 +671,8 @@ def from_origin(cls, terminations): # Check for non-symmetric path if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): is_complete = True + elif len(remote_terminations) == 0: + is_complete = False else: # Unsupported topology, mark as split and exit is_complete = False From 359b366b275852ad4dd1ab83a052a55135e63a5b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 Sep 2023 13:24:00 -0500 Subject: [PATCH 16/22] Improve handling of links with multi-terminations --- netbox/dcim/models/cables.py | 20 ++++++++++++++++++-- netbox/dcim/tests/test_cablepaths.py | 6 ++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 21f1e946651..0b5dbb92a5c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -551,8 +551,12 @@ def from_origin(cls, terminations): is_complete = False is_split = True - # Step 4: Record the links - path.append([object_to_path_node(link) for link in links]) + # Step 4: Record the links, keeping cables in order to allow for SVG rendering + cables = [] + for link in links: + if object_to_path_node(link) not in cables: + cables.append(object_to_path_node(link)) + path.append(cables) # Step 5: Update the path status if a link is not connected links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED] @@ -782,3 +786,15 @@ def get_split_nodes(self): return [ ct.get_peer_termination() for ct in nodes ] + + def get_asymmetric_nodes(self): + """ + Return all available next segments in a split cable path. + """ + from circuits.models import CircuitTermination + asymmetric_nodes = [] + for nodes in self.path_objects: + if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]: + asymmetric_nodes.extend([node for node in nodes if node.link is None]) + + return asymmetric_nodes diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index bad5157425a..73093be6d3b 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -50,9 +50,7 @@ def _get_cablepath(self, nodes, **kwargs): path.append([object_to_path_node(node) for node in step]) else: path.append([object_to_path_node(step)]) - - cablepath = CablePath.objects.filter(path=path, **kwargs).first() - return cablepath + return CablePath.objects.filter(path=path, **kwargs).first() def assertPathExists(self, nodes, **kwargs): """ @@ -333,7 +331,7 @@ def test_121_multi_interface_to_multi_interface(self): is_active=True ) path2 = self.assertPathExists( - ((interface3, interface4), (cable1, cable1), (interface1, interface2)), + ((interface3, interface4), cable1, (interface1, interface2)), is_complete=True, is_active=True ) From 5bb373fe2a3c9a762781c3edb94e2a1f1e1ddcf4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 Sep 2023 13:32:29 -0500 Subject: [PATCH 17/22] Improved SVG rendering of multiple rear ports (with positions) per path trace. Include asymmetric path detection --- netbox/dcim/svg/cables.py | 115 +++++++++++++++++-------- netbox/templates/dcim/cable_trace.html | 10 ++- 2 files changed, 86 insertions(+), 39 deletions(-) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 9413726fa52..eeafcc50dfe 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -34,9 +34,13 @@ class Node(Hyperlink): radius: Box corner radius, for rounded corners (default: 10) """ - def __init__(self, position, width, url, color, labels, radius=10, **extra): + object = None + + def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra): super(Node, self).__init__(href=url, target='_parent', **extra) + self.object = object + x, y = position # Add the box @@ -77,7 +81,7 @@ class Connector(Group): labels: Iterable of text labels """ - def __init__(self, start, url, color, labels=[], **extra): + def __init__(self, start, url, color, labels=[], description=[], **extra): super().__init__(class_='connector', **extra) self.start = start @@ -104,6 +108,8 @@ def __init__(self, start, url, color, labels=[], **extra): text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) text = Text(label, insert=text_coords, class_='bold' if not i else []) link.add(text) + if len(description) > 0: + link.set_desc("\n".join(description)) self.add(link) @@ -206,7 +212,8 @@ def draw_terminations(self, terminations): url=f'{self.base_url}{term.get_absolute_url()}', color=self._get_color(term), labels=self._get_labels(term), - radius=5 + radius=5, + object=term ) nodes_height = max(nodes_height, node.box['height']) nodes.append(node) @@ -238,20 +245,39 @@ def draw_fanout(self, node, connector): Polyline(points=points, style=f'stroke: #{connector.color}'), )) - def draw_cable(self, cable): - labels = [ - f'Cable {cable}', - cable.get_status_display() - ] - if cable.type: - labels.append(cable.get_type_display()) - if cable.length and cable.length_unit: - labels.append(f'{cable.length} {cable.get_length_unit_display()}') + def draw_cable(self, cable, terminations, cable_count=0): + if cable_count > 2: + labels = [f'{cable}'] + description = [ + f'Cable {cable}', + cable.get_status_display() + ] + if cable.type: + description.append(cable.get_type_display()) + if cable.length and cable.length_unit: + description.append(f'{cable.length} {cable.get_length_unit_display()}') + else: + labels = [ + f'Cable {cable}', + cable.get_status_display() + ] + description = [] + if cable.type: + labels.append(cable.get_type_display()) + if cable.length and cable.length_unit: + labels.append(f'{cable.length} {cable.get_length_unit_display()}') + + if len(terminations) == 1: + center = terminations[0].bottom_center[0] + else: + termination_centers = [term.bottom_center[0] for term in terminations] + center = sum(termination_centers) / len(termination_centers) connector = Connector( - start=(self.center + OFFSET, self.cursor), + start=(center, self.cursor), color=cable.color or '000000', url=f'{self.base_url}{cable.get_absolute_url()}', - labels=labels + labels=labels, + description=description ) self.cursor += connector.height @@ -334,34 +360,47 @@ def render(self): # Connector (a Cable or WirelessLink) if links: - link = links[0] # Remove Cable from list - - # Cable - if type(link) is Cable: - - # Account for fan-ins height - if len(near_ends) > 1: - self.cursor += FANOUT_HEIGHT - - cable = self.draw_cable(link) - self.connectors.append(cable) - - # Draw fan-ins - if len(near_ends) > 1: - for term in terminations: - self.draw_fanin(term, cable) + link_cables = {} + fanin = False + fanout = False - # WirelessLink - elif type(link) is WirelessLink: - wirelesslink = self.draw_wirelesslink(link) - self.connectors.append(wirelesslink) + # Determine if we have fanins or fanouts + if len(near_ends) > len(set(links)): + self.cursor += FANOUT_HEIGHT + fanin = True + if len(far_ends) > len(set(links)): + fanout = True + cursor = self.cursor + for link in links: + # Cable + if type(link) is Cable and not link_cables.get(link.pk): + self.cursor = cursor + near_end_link_terminations = [term for term in terminations if term.object.cable == link] + cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) + link_cables.update({link.pk: cable}) + self.connectors.append(cable) + + # Draw fan-ins + if len(near_ends) > 1 and fanin: + for term in terminations: + if term.object.cable == link: + self.draw_fanin(term, cable) + + # WirelessLink + elif type(link) is WirelessLink: + wirelesslink = self.draw_wirelesslink(link) + self.connectors.append(wirelesslink) # Far end termination(s) if len(far_ends) > 1: - self.cursor += FANOUT_HEIGHT - terminations = self.draw_terminations(far_ends) - for term in terminations: - self.draw_fanout(term, cable) + if fanout: + self.cursor += FANOUT_HEIGHT + terminations = self.draw_terminations(far_ends) + for term in terminations: + if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): + self.draw_fanout(term, link_cables.get(term.object.cable.pk)) + else: + self.draw_terminations(far_ends) elif far_ends: self.draw_terminations(far_ends) else: diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 12000f09d57..5fa56bdbae3 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -23,7 +23,15 @@
- {% if path.is_split %} + {% if path.is_split and path.get_asymmetric_nodes %} +

{% trans "Asymmetric Path" %}!

+

{% trans "The nodes below have no links and result in an asymmetric path" %}:

+
    + {% for next_node in path.get_asymmetric_nodes %} +
  • {{ next_node|linkify }}
  • + {% endfor %} +
+ {% elif path.is_split %}

{% trans "Path split" %}!

{% trans "Select a node below to continue" %}:

    From 4ae7e2f9a84a034cfe2625ccb6414ca975c67e69 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 12 Sep 2023 13:49:57 -0500 Subject: [PATCH 18/22] Include missing assert to ensure links are same type. --- netbox/dcim/models/cables.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 0b5dbb92a5c..7c88cfa4636 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -544,6 +544,7 @@ def from_origin(cls, terminations): # Otherwise, halt the trace if no link exists break assert all(type(link) in (Cable, WirelessLink) for link in links) + assert all(isinstance(link, type(links[0])) for link in links) # Step 3: Record asymmetric paths as split not_connected_terminations = [termination.link for termination in terminations if termination.link is None] From 58ca48987baaadd0226a20680b3fe75a8bf9b6bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Sep 2023 10:38:49 -0400 Subject: [PATCH 19/22] Clean up tests --- netbox/dcim/tests/test_cablepaths.py | 30 ++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 73093be6d3b..783642ce16d 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -40,7 +40,6 @@ def _get_cablepath(self, nodes, **kwargs): Return a given cable path :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) :return: The matching CablePath (if any) """ @@ -54,31 +53,24 @@ def _get_cablepath(self, nodes, **kwargs): def assertPathExists(self, nodes, **kwargs): """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. + Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the + first matching CablePath, if found. :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) - - :return: The matching CablePath (if any) """ cablepath = self._get_cablepath(nodes, **kwargs) self.assertIsNotNone(cablepath, msg='CablePath not found') return cablepath - def assertPathNotExists(self, nodes, **kwargs): + def assertPathDoesNotExist(self, nodes, **kwargs): """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. + Assert that a specific CablePath does *not* exist. :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) - - :return: The matching CablePath (if any) """ cablepath = self._get_cablepath(nodes, **kwargs) - self.assertIsNone(cablepath, msg='CablePath not found') - - return cablepath + self.assertIsNone(cablepath, msg='Unexpected CablePath found') def assertPathIsSet(self, origin, cablepath, msg=None): """ @@ -2044,8 +2036,8 @@ def test_303_remove_termination_from_existing_cable(self): def test_401_non_symmetric_paths(self): """ - [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] - [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] -------------------------------------------C3-- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --/ """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') @@ -2165,7 +2157,7 @@ def test_401_non_symmetric_paths(self): def test_402_exclude_midspan_devices(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] - [FP3] [RP3] --C4-- [RP4] [FP4] / + [FP3] [RP3] --C4-- [RP4] [FP4] """ device = Device.objects.create( site=self.site, @@ -2216,7 +2208,7 @@ def test_402_exclude_midspan_devices(self): cable1.save() except AssertionError: pass - self.assertPathNotExists( + self.assertPathDoesNotExist( ( interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4) @@ -2236,7 +2228,7 @@ def test_402_exclude_midspan_devices(self): cable3.save() except AssertionError: pass - self.assertPathNotExists( + self.assertPathDoesNotExist( ( interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), (rearport1, rearport3), (frontport1, frontport2), cable1, interface1 @@ -2244,7 +2236,7 @@ def test_402_exclude_midspan_devices(self): is_complete=True, is_active=True ) - self.assertPathNotExists( + self.assertPathDoesNotExist( ( interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 From fc8b5e17e4e554b55a09d02857462df0d19e41bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Sep 2023 10:58:42 -0400 Subject: [PATCH 20/22] Remove unused objects from tests --- netbox/dcim/tests/test_cablepaths.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 783642ce16d..eb284fde5f5 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -25,7 +25,6 @@ def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') - module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module') role = DeviceRole.objects.create(name='Device Role', slug='device-role') cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=role, name='Test Device') @@ -1790,11 +1789,6 @@ def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(sel [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] """ - module_type = ModuleType.objects.get(model='Test Module') - module_bay1 = ModuleBay.objects.create(device=self.device, name='Module Bay 1', position='1') - module_bay2 = ModuleBay.objects.create(device=self.device, name='Module Bay 2', position='2') - module_bay3 = ModuleBay.objects.create(device=self.device, name='Module Bay 3', position='3') - module_bay4 = ModuleBay.objects.create(device=self.device, name='Module Bay 4', position='4') interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface3 = Interface.objects.create(device=self.device, name='Interface 3') From d87df88e42189f22fc6b36c810215047e08c1c81 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 19 Sep 2023 11:07:59 -0500 Subject: [PATCH 21/22] Changes requested to tests and update comments/doctstrings --- netbox/dcim/models/cables.py | 2 +- netbox/dcim/svg/cables.py | 32 ++++ netbox/dcim/tests/test_cablepaths.py | 256 +++++++++++++-------------- 3 files changed, 160 insertions(+), 130 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7c88cfa4636..d364d0bab1c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -520,7 +520,7 @@ def from_origin(cls, terminations): # All mid-span terminations must all be attached to the same device if not isinstance(terminations[0], PathEndpoint): - assert all(t.device == terminations[0].device for t in terminations[1:]) + assert all(t.parent == terminations[0].parent for t in terminations[1:]) # Check for a split path (e.g. rear port fanning out to multiple front ports with # different cables attached) diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index eeafcc50dfe..c01e656fdb2 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -32,6 +32,8 @@ class Node(Hyperlink): color: Box fill color (RRGGBB format) labels: An iterable of text strings. Each label will render on a new line within the box. radius: Box corner radius, for rounded corners (default: 10) + object: A copy of the object to allow reference when drawing cables to determine which cables are connected to + which terminations. """ object = None @@ -39,6 +41,7 @@ class Node(Hyperlink): def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra): super(Node, self).__init__(href=url, target='_parent', **extra) + # Save object for reference by cable systems self.object = object x, y = position @@ -246,15 +249,31 @@ def draw_fanout(self, node, connector): )) def draw_cable(self, cable, terminations, cable_count=0): + """ + Draw a single cable. Terminations and cable count are passed for determining position and padding + + :param cable: The cable to draw + :param terminations: List of terminations to build positioning data off of + :param cable_count: Count of all cables on this layer for determining whether to collapse description into a + tooltip. + """ + + # If the cable count is higher than 2, collapse the description into a tooltip if cable_count > 2: + # Use the cable __str__ function to denote the cable labels = [f'{cable}'] + + # Include the label and the status description in the tooltip description = [ f'Cable {cable}', cable.get_status_display() ] + if cable.type: + # Include the cable type in the tooltip description.append(cable.get_type_display()) if cable.length and cable.length_unit: + # Include the cable length in the tooltip description.append(f'{cable.length} {cable.get_length_unit_display()}') else: labels = [ @@ -265,13 +284,20 @@ def draw_cable(self, cable, terminations, cable_count=0): if cable.type: labels.append(cable.get_type_display()) if cable.length and cable.length_unit: + # Include the cable length in the tooltip labels.append(f'{cable.length} {cable.get_length_unit_display()}') + # If there is only one termination, center on that termination + # Otherwise average the center across the terminations if len(terminations) == 1: center = terminations[0].bottom_center[0] else: + # Get a list of termination centers termination_centers = [term.bottom_center[0] for term in terminations] + # Average the centers center = sum(termination_centers) / len(termination_centers) + + # Create the connector connector = Connector( start=(center, self.cursor), color=cable.color or '000000', @@ -280,6 +306,7 @@ def draw_cable(self, cable, terminations, cable_count=0): description=description ) + # Set the cursor position self.cursor += connector.height return connector @@ -374,10 +401,15 @@ def render(self): for link in links: # Cable if type(link) is Cable and not link_cables.get(link.pk): + # Reset cursor self.cursor = cursor + # Generate a list of terminations connected to this cable near_end_link_terminations = [term for term in terminations if term.object.cable == link] + # Draw the cable cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) + # Add cable to the list of cables link_cables.update({link.pk: cable}) + # Add cable to drawing self.connectors.append(cable) # Draw fan-ins diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 73093be6d3b..2874aa3a47a 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1891,6 +1891,126 @@ def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(sel ) self.assertEqual(CablePath.objects.count(), 3) + def test_221_non_symmetric_paths(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/ + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) + rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + frontport5 = FrontPort.objects.create( + device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1 + ) + frontport6 = FrontPort.objects.create( + device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + cable6 = Cable( + a_terminations=[frontport4], + b_terminations=[frontport5], + label='C6' + ) + cable6.save() + cable7 = Cable( + a_terminations=[rearport5], + b_terminations=[rearport6], + label='C7' + ) + cable7.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1], + label='C1' + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3], + label='C5' + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport6], + b_terminations=[interface2], + label='C3' + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7), + (rearport1, rearport5), (frontport1, frontport5), (cable1, cable6) + ), + is_complete=False, + is_split=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] @@ -2042,130 +2162,10 @@ def test_303_remove_termination_from_existing_cable(self): is_active=True ) - def test_401_non_symmetric_paths(self): + def test_401_exclude_midspan_devices(self): """ - [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] - [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/ - """ - interface1 = Interface.objects.create(device=self.device, name='Interface 1') - interface2 = Interface.objects.create(device=self.device, name='Interface 2') - interface3 = Interface.objects.create(device=self.device, name='Interface 3') - rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) - rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) - rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) - rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1) - frontport1 = FrontPort.objects.create( - device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 - ) - frontport2 = FrontPort.objects.create( - device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 - ) - frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 - ) - frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 - ) - frontport5 = FrontPort.objects.create( - device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1 - ) - frontport6 = FrontPort.objects.create( - device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1 - ) - - cable2 = Cable( - a_terminations=[rearport1], - b_terminations=[rearport2], - label='C2' - ) - cable2.save() - cable4 = Cable( - a_terminations=[rearport3], - b_terminations=[rearport4], - label='C4' - ) - cable4.save() - cable6 = Cable( - a_terminations=[frontport4], - b_terminations=[frontport5], - label='C6' - ) - cable6.save() - cable7 = Cable( - a_terminations=[rearport5], - b_terminations=[rearport6], - label='C7' - ) - cable7.save() - self.assertEqual(CablePath.objects.count(), 0) - - # Create cable1 - cable1 = Cable( - a_terminations=[interface1], - b_terminations=[frontport1], - label='C1' - ) - cable1.save() - self.assertPathExists( - ( - interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 - ), - is_complete=False - ) - # Create cable1 - cable5 = Cable( - a_terminations=[interface3], - b_terminations=[frontport3], - label='C5' - ) - cable5.save() - self.assertPathExists( - ( - interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, - cable7, rearport6, frontport6 - ), - is_complete=False - ) - self.assertEqual(CablePath.objects.count(), 2) - - # Create cable 3 - cable3 = Cable( - a_terminations=[frontport2, frontport6], - b_terminations=[interface2], - label='C3' - ) - cable3.save() - self.assertPathExists( - ( - interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7), - (rearport1, rearport5), (frontport1, frontport5), (cable1, cable6) - ), - is_complete=False, - is_split=True - ) - self.assertPathExists( - ( - interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 - ), - is_complete=True, - is_active=True - ) - self.assertPathExists( - ( - interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, - cable7, rearport6, frontport6, cable3, interface2 - ), - is_complete=True, - is_active=True - ) - self.assertEqual(CablePath.objects.count(), 3) - - def test_402_exclude_midspan_devices(self): - """ - [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] - [FP3] [RP3] --C4-- [RP4] [FP4] / + [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2] + [FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] / """ device = Device.objects.create( site=self.site, @@ -2212,10 +2212,9 @@ def test_402_exclude_midspan_devices(self): b_terminations=[frontport1, frontport3], label='C1' ) - try: + with self.assertRaises(AssertionError): cable1.save() - except AssertionError: - pass + self.assertPathNotExists( ( interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), @@ -2232,10 +2231,9 @@ def test_402_exclude_midspan_devices(self): label='C3' ) - try: + with self.assertRaises(AssertionError): cable3.save() - except AssertionError: - pass + self.assertPathNotExists( ( interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), From 704f9abbf69c168af644d91f283756b1290f788f Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 19 Sep 2023 11:25:21 -0500 Subject: [PATCH 22/22] Fix parent reference --- netbox/dcim/models/cables.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index d364d0bab1c..16cd1ec5591 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -520,7 +520,8 @@ def from_origin(cls, terminations): # All mid-span terminations must all be attached to the same device if not isinstance(terminations[0], PathEndpoint): - assert all(t.parent == terminations[0].parent for t in terminations[1:]) + assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:]) # Check for a split path (e.g. rear port fanning out to multiple front ports with # different cables attached)