diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c16e2cab1..218300a53 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -934,7 +934,17 @@ class CablePath(models.Model): # Profile-based tracing if links[0].profile: cable_profile = links[0].profile_class() - positions = position_stack.pop() if position_stack else [None] + if position_stack: + positions = position_stack.pop() + else: + # When the position stack is empty (e.g. the trace reached this + # profiled cable after crossing single-position pass-through ports + # which don't push onto the stack), derive positions from each + # termination's own cable_positions — which were set by this + # profiled cable when it was saved. + positions = [ + pos for term in terminations for pos in (term.cable_positions or []) + ] remote_terminations = [] new_positions = [] @@ -951,9 +961,8 @@ class CablePath(models.Model): remaining[cp] -= 1 # Fallback for when positions don't match cable_positions - # (e.g., empty position stack yielding [None]) if not term_position_pairs: - term_position_pairs = [(terminations[0], pos) for pos in positions] + term_position_pairs = [(terminations[0], pos) for pos in positions or [None]] peer_results = cable_profile.get_peer_terminations(term_position_pairs) seen = set() diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index bce573b79..7b1233f61 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -1857,6 +1857,103 @@ class CablePathTestCase(CablePathTestCase): ) self.assertEqual(CablePath.objects.count(), 4) + def test_225_breakout_1c2p_2c1p_to_single_position_passthroughs(self): + """ + Regression test for #22187: a 1C2P:2C1P breakout cable terminating on two + single-position FrontPorts (each mapping 1:1 to a single-position RearPort) + which are then connected to PathEndpoints via unprofiled cables. + + [IF1] --C1 (1C2P:2C1P)-- [FP1] [RP1] --C2 (unprofiled)-- [IF2] + [FP2] [RP2] --C3 (unprofiled)-- [IF3] + """ + interfaces = [ + Interface.objects.create(device=self.device, name='Interface 1'), + Interface.objects.create(device=self.device, name='Interface 2'), + Interface.objects.create(device=self.device, name='Interface 3'), + ] + rear_ports = [ + RearPort.objects.create(device=self.device, name='Rear Port 1'), + RearPort.objects.create(device=self.device, name='Rear Port 2'), + ] + front_ports = [ + FrontPort.objects.create(device=self.device, name='Front Port 1'), + FrontPort.objects.create(device=self.device, name='Front Port 2'), + ] + PortMapping.objects.bulk_create([ + PortMapping( + device=self.device, + front_port=front_ports[0], + front_port_position=1, + rear_port=rear_ports[0], + rear_port_position=1, + ), + PortMapping( + device=self.device, + front_port=front_ports[1], + front_port_position=1, + rear_port=rear_ports[1], + rear_port_position=1, + ), + ]) + + # Create cables + cable1 = Cable( + profile=CableProfileChoices.BREAKOUT_1C2P_2C1P, + a_terminations=[interfaces[0]], + b_terminations=[front_ports[0], front_ports[1]], + ) + cable1.clean() + cable1.save() + cable2 = Cable( + a_terminations=[rear_ports[0]], + b_terminations=[interfaces[1]], + ) + cable2.clean() + cable2.save() + cable3 = Cable( + a_terminations=[rear_ports[1]], + b_terminations=[interfaces[2]], + ) + cable3.clean() + cable3.save() + + # The breakout splits IF1's two positions into separate downstream cables, + # so the forward path has FP1/FP2, RP1/RP2, and the two unprofiled cables + # at the corresponding hops. + path1 = self.assertPathExists( + ( + interfaces[0], + cable1, + [front_ports[0], front_ports[1]], + [rear_ports[0], rear_ports[1]], + [cable2, cable3], + [interfaces[1], interfaces[2]], + ), + is_complete=True, + is_active=True, + ) + path2 = self.assertPathExists( + (interfaces[1], cable2, rear_ports[0], front_ports[0], cable1, interfaces[0]), + is_complete=True, + is_active=True, + ) + path3 = self.assertPathExists( + (interfaces[2], cable3, rear_ports[1], front_ports[1], cable1, interfaces[0]), + is_complete=True, + is_active=True, + ) + self.assertEqual(CablePath.objects.count(), 3) + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + + # Test SVG generation from both directions + CableTraceSVG(interfaces[0]).render() + CableTraceSVG(interfaces[1]).render() + CableTraceSVG(interfaces[2]).render() + def test_304_add_port_mapping_between_connected_ports(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]