From 4da65854e1f1362168a42b52811c2118a2464277 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Fri, 26 Jun 2026 16:02:39 +0200 Subject: [PATCH] feat(dcim): Add 1C8P:8C1P breakout cable profile Introduce Breakout1C8Px8C1PCableProfile to map a single 8-position connector to eight single-position connectors. Add profile choice, registration, and bidirectional link peer tests for the new breakout. Fixes #22279 --- netbox/dcim/cable_profiles.py | 33 +++++++++++++++ netbox/dcim/choices.py | 2 + netbox/dcim/models/cables.py | 1 + netbox/dcim/tests/test_cable_profiles.py | 53 ++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 921392dc0..d5c18f989 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -409,6 +409,39 @@ class Breakout1C6Px6C1PCableProfile(BaseCableProfile): } +class Breakout1C8Px8C1PCableProfile(BaseCableProfile): + a_connectors = { + 1: 8, + } + b_connectors = { + 1: 1, + 2: 1, + 3: 1, + 4: 1, + 5: 1, + 6: 1, + 7: 1, + 8: 1, + } + _mapping = { + (1, 1): (1, 1), + (1, 2): (2, 1), + (1, 3): (3, 1), + (1, 4): (4, 1), + (1, 5): (5, 1), + (1, 6): (6, 1), + (1, 7): (7, 1), + (1, 8): (8, 1), + (2, 1): (1, 2), + (3, 1): (1, 3), + (4, 1): (1, 4), + (5, 1): (1, 5), + (6, 1): (1, 6), + (7, 1): (1, 7), + (8, 1): (1, 8), + } + + class Trunk2C4PShuffleCableProfile(BaseCableProfile): a_connectors = { 1: 4, diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 58011b5f7..c1503128f 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1787,6 +1787,7 @@ class CableProfileChoices(ChoiceSet): BREAKOUT_1C2P_2C1P = 'breakout-1c2p-2c1p' BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p' BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p' + BREAKOUT_1C8P_8C1P = 'breakout-1c8p-8c1p' BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle' CHOICES = ( @@ -1827,6 +1828,7 @@ class CableProfileChoices(ChoiceSet): (BREAKOUT_1C2P_2C1P, _('1C2P:2C1P breakout')), (BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')), (BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')), + (BREAKOUT_1C8P_8C1P, _('1C8P:8C1P breakout')), (BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')), ), ), diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index d1c70d942..a7d1e366b 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -207,6 +207,7 @@ class Cable(PrimaryModel): CableProfileChoices.BREAKOUT_1C2P_2C1P: cable_profiles.Breakout1C2Px2C1PCableProfile, CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile, CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile, + CableProfileChoices.BREAKOUT_1C8P_8C1P: cable_profiles.Breakout1C8Px8C1PCableProfile, CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile, }.get(self.profile) diff --git a/netbox/dcim/tests/test_cable_profiles.py b/netbox/dcim/tests/test_cable_profiles.py index 89299bf4f..872aea7f6 100644 --- a/netbox/dcim/tests/test_cable_profiles.py +++ b/netbox/dcim/tests/test_cable_profiles.py @@ -2,6 +2,7 @@ from django.test import tag from dcim.cable_profiles import ( Breakout1C4Px4C1PCableProfile, + Breakout1C8Px8C1PCableProfile, Single1C1PCableProfile, Single1C4PCableProfile, Trunk2C2PCableProfile, @@ -74,6 +75,32 @@ class CableProfileLinkPeerTestCase(BaseCablePathTestCase): for interface in interfaces[2:4] + interfaces[6:8]: self.assertEqual(interface.link_peers, [rear_ports[1]]) + def test_breakout_1c8p_8c1p_link_peers(self): + """ + Link peers for a 1C8P:8C1P breakout map the single A-side connector's + eight positions to the eight B-side connectors, and each back to it. + """ + a_interface = Interface.objects.create(device=self.device, name='Interface A') + b_interfaces = [ + Interface.objects.create(device=self.device, name=f'Interface B{i}') for i in range(1, 9) + ] + + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C8P_8C1P, + a_terminations=[a_interface], + b_terminations=b_interfaces, + ) + cable.clean() + cable.save() + + a_interface.refresh_from_db() + for iface in b_interfaces: + iface.refresh_from_db() + + self.assertEqual(a_interface.link_peers, b_interfaces) + for iface in b_interfaces: + self.assertEqual(iface.link_peers, [a_interface]) + class CableProfilePeerTerminationTestCase(BaseCablePathTestCase): """ @@ -228,6 +255,32 @@ class CableProfilePeerTerminationTestCase(BaseCablePathTestCase): b_pairs = [(iface, 1) for iface in self.interfaces[9:13]] self._assert_batch_matches_singular(profile, b_pairs) + def test_breakout_1c8p_profile(self): + """ + Batch resolution on a 1C8P:8C1P breakout maps one A-side connector's + eight positions to eight B-side connectors. + """ + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C8P_8C1P, + a_terminations=[self.interfaces[0]], + b_terminations=self.interfaces[1:9], + ) + cable.clean() + cable.save() + + self.interfaces[0].refresh_from_db() + for iface in self.interfaces[1:9]: + iface.refresh_from_db() + profile = Breakout1C8Px8C1PCableProfile() + + # A→B direction (one connector, 8 positions → 8 connectors) + a_pairs = [(self.interfaces[0], pos) for pos in self.interfaces[0].cable_positions] + self._assert_batch_matches_singular(profile, a_pairs) + + # B→A direction (8 connectors, 1 position each → one connector) + b_pairs = [(iface, 1) for iface in self.interfaces[1:9]] + self._assert_batch_matches_singular(profile, b_pairs) + def test_multi_position_single_termination(self): """ When a single-connector multi-position profile has only one termination