Explorar o código

Additional work for FR #20788 (#20973)

Jeremy Stretch hai 2 meses
pai
achega
875e3e7979

+ 1 - 1
netbox/circuits/filtersets.py

@@ -353,7 +353,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
         model = CircuitTermination
         fields = (
             'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description',
-            'mark_connected', 'pp_info', 'cable_end', 'cable_position',
+            'mark_connected', 'pp_info', 'cable_end', 'cable_connector',
         )
 
     def search(self, queryset, name, value):

+ 39 - 0
netbox/circuits/migrations/0054_cable_connector_positions.py

@@ -0,0 +1,39 @@
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0053_owner'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittermination',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+    ]

+ 0 - 23
netbox/circuits/migrations/0054_cable_position.py

@@ -1,23 +0,0 @@
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ('circuits', '0053_owner'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='circuittermination',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-    ]

+ 1 - 1
netbox/circuits/migrations/0055_add_comments_to_organizationalmodel.py

@@ -6,7 +6,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('circuits', '0054_cable_position'),
+        ('circuits', '0054_cable_connector_positions'),
     ]
 
     operations = [

+ 1 - 1
netbox/circuits/tests/test_filtersets.py

@@ -433,7 +433,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
 class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CircuitTermination.objects.all()
     filterset = CircuitTerminationFilterSet
-    ignore_fields = ('cable',)
+    ignore_fields = ('cable', 'cable_positions')
 
     @classmethod
     def setUpTestData(cls):

+ 3 - 2
netbox/dcim/api/serializers_/cables.py

@@ -61,11 +61,12 @@ class CableTerminationSerializer(NetBoxModelSerializer):
         model = CableTermination
         fields = [
             'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
-            'termination', 'position', 'created', 'last_updated',
+            'termination', 'connector', 'positions', 'created', 'last_updated',
         ]
         read_only_fields = fields
         brief_fields = (
-            'id', 'url', 'display', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id',
+            'id', 'url', 'display', 'cable', 'cable_end', 'connector', 'positions', 'termination_type',
+            'termination_id',
         )
 
 

+ 345 - 63
netbox/dcim/cable_profiles.py

@@ -1,108 +1,390 @@
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
+from dcim.choices import CableEndChoices
 from dcim.models import CableTermination
 
 
 class BaseCableProfile:
-    # Maximum number of terminations allowed per side
-    a_max_connections = None
-    b_max_connections = None
+    """Base class for representing a cable profile."""
+
+    # Mappings of connectors to the number of positions presented by each, at either end of the cable. For example, a
+    # 12-strand MPO fiber cable would have one connector at either end with six positions (six bidirectional fiber
+    # pairs).
+    a_connectors = {}
+    b_connectors = {}
+
+    # Defined a mapping of A/B connector & position pairings. If not defined, all positions are presumed to be
+    # symmetrical (i.e. 1:1 on side A maps to 1:1 on side B). If defined, it must be constructed as a dictionary of
+    # two-item tuples, e.g. {(1, 1): (1, 1)}.
+    _mapping = None
 
     def clean(self, cable):
-        # Enforce maximum connection limits
-        if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections:
+        # Enforce maximum terminations limits
+        a_terminations_count = len(cable.a_terminations)
+        b_terminations_count = len(cable.b_terminations)
+        max_a_terminations = len(self.a_connectors)
+        max_b_terminations = len(self.b_connectors)
+        if a_terminations_count > max_a_terminations:
             raise ValidationError({
                 'a_terminations': _(
-                    'Maximum A side connections for profile {profile}: {max}'
+                    'A side of cable has {count} terminations but only {max} are permitted for profile {profile}'
                 ).format(
+                    count=a_terminations_count,
                     profile=cable.get_profile_display(),
-                    max=self.a_max_connections,
+                    max=max_a_terminations,
                 )
             })
-        if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections:
+        if b_terminations_count > max_b_terminations:
             raise ValidationError({
                 'b_terminations': _(
-                    'Maximum B side connections for profile {profile}: {max}'
+                    'B side of cable has {count} terminations but only {max} are permitted for profile {profile}'
                 ).format(
+                    count=b_terminations_count,
                     profile=cable.get_profile_display(),
-                    max=self.b_max_connections,
+                    max=max_b_terminations,
                 )
             })
 
-    def get_mapped_position(self, side, position):
+    def get_mapped_position(self, side, connector, position):
+        """
+        Return the mapped far-end connector & position for a given cable end the local connector & position.
         """
-        Return the mapped position for a given cable end and position.
+        # By default, assume all positions are symmetrical.
+        if self._mapping:
+            return self._mapping.get((connector, position))
+        return connector, position
 
-        By default, assume all positions are symmetrical.
+    def get_peer_termination(self, termination, position):
         """
-        return position
+        Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable.
+        """
+        try:
+            connector, position = self.get_mapped_position(
+                termination.cable_end,
+                termination.cable_connector,
+                position
+            )
+        except TypeError:
+            raise ValueError(
+                f"Could not map connector {termination.cable_connector} position {position} on side "
+                f"{termination.cable_end}"
+            )
+        try:
+            ct = CableTermination.objects.get(
+                cable=termination.cable,
+                cable_end=termination.opposite_cable_end,
+                connector=connector,
+                positions__contains=[position],
+            )
+            return ct.termination, position
+        except CableTermination.DoesNotExist:
+            return None, None
 
-    def get_peer_terminations(self, terminations, position_stack):
-        local_end = terminations[0].cable_end
-        qs = CableTermination.objects.filter(
-            cable=terminations[0].cable,
-            cable_end=terminations[0].opposite_cable_end
-        )
+    @staticmethod
+    def get_position_list(n):
+        """Return a list of integers from 1 to n, inclusive."""
+        return list(range(1, n + 1))
 
-        # TODO: Optimize this to use a single query under any condition
-        if position_stack:
-            # Attempt to find a peer termination at the same position currently in the stack. Pop the stack only if
-            # we find one. Otherwise, return any peer terminations with a null position.
-            position = self.get_mapped_position(local_end, position_stack[-1][0])
-            if peers := qs.filter(position=position):
-                position_stack.pop()
-                return peers
 
-        return qs.filter(position=None)
+# Profile naming:
+#  - Single: One connector per side, with one or more positions
+#  - Trunk: Two or more connectors per side, with one or more positions per connector
+#  - Breakout: One or more connectors on the A side which map to a greater number of B side connectors
+#  - Shuffle: A cable with nonlinear position mappings between sides
 
+class Single1C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 1,
+    }
+    b_connectors = a_connectors
 
-class StraightSingleCableProfile(BaseCableProfile):
-    a_max_connections = 1
-    b_max_connections = 1
 
+class Single1C2PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 2,
+    }
+    b_connectors = a_connectors
 
-class StraightMultiCableProfile(BaseCableProfile):
-    a_max_connections = None
-    b_max_connections = None
 
+class Single1C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+    }
+    b_connectors = a_connectors
 
-class Shuffle2x2MPO8CableProfile(BaseCableProfile):
-    a_max_connections = 8
-    b_max_connections = 8
-    _mapping = {
+
+class Single1C6PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C8PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 8,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C12PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 12,
+    }
+    b_connectors = a_connectors
+
+
+class Single1C16PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 16,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C1PCableProfile(BaseCableProfile):
+    a_connectors = {
         1: 1,
+        2: 1,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C2PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 2,
+        2: 2,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C6PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+        2: 6,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C8PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 8,
+        2: 8,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk2C12PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 12,
+        2: 12,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 1,
+        2: 1,
+        3: 1,
+        4: 1,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C2PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 2,
         2: 2,
-        3: 5,
+        3: 2,
+        4: 2,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+        3: 4,
+        4: 4,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C6PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+        2: 6,
+        3: 6,
         4: 6,
-        5: 3,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk4C8PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 8,
+        2: 8,
+        3: 8,
+        4: 8,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk8C4PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+        3: 4,
+        4: 4,
+        5: 4,
         6: 4,
-        7: 7,
-        8: 8,
+        7: 4,
+        8: 4,
     }
+    b_connectors = a_connectors
 
-    def get_mapped_position(self, side, position):
-        return self._mapping.get(position)
 
+class Breakout1C4Px4C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+    }
+    b_connectors = {
+        1: 1,
+        2: 1,
+        3: 1,
+        4: 1,
+    }
+    _mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (2, 1),
+        (1, 3): (3, 1),
+        (1, 4): (4, 1),
+        (2, 1): (1, 2),
+        (3, 1): (1, 3),
+        (4, 1): (1, 4),
+    }
 
-class Shuffle4x4MPO8CableProfile(BaseCableProfile):
-    a_max_connections = 8
-    b_max_connections = 8
-    # A side to B side position mapping
-    _a_mapping = {
+
+class Breakout1C6Px6C1PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 6,
+    }
+    b_connectors = {
         1: 1,
-        2: 3,
-        3: 5,
-        4: 7,
-        5: 2,
-        6: 4,
-        7: 6,
-        8: 8,
+        2: 1,
+        3: 1,
+        4: 1,
+        5: 1,
+        6: 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),
+        (2, 1): (1, 2),
+        (3, 1): (1, 3),
+        (4, 1): (1, 4),
+        (5, 1): (1, 5),
+        (6, 1): (1, 6),
+    }
+
+
+class Trunk2C4PShuffleCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+    }
+    b_connectors = a_connectors
+    _mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (1, 2),
+        (1, 3): (2, 1),
+        (1, 4): (2, 2),
+        (2, 1): (1, 3),
+        (2, 2): (1, 4),
+        (2, 3): (2, 3),
+        (2, 4): (2, 4),
+    }
+
+
+class Trunk4C4PShuffleCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+        3: 4,
+        4: 4,
+    }
+    b_connectors = a_connectors
+    _mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (2, 1),
+        (1, 3): (3, 1),
+        (1, 4): (4, 1),
+        (2, 1): (1, 2),
+        (2, 2): (2, 2),
+        (2, 3): (3, 2),
+        (2, 4): (4, 2),
+        (3, 1): (1, 3),
+        (3, 2): (2, 3),
+        (3, 3): (3, 3),
+        (3, 4): (4, 3),
+        (4, 1): (1, 4),
+        (4, 2): (2, 4),
+        (4, 3): (3, 4),
+        (4, 4): (4, 4),
+    }
+
+
+class Breakout2C4Px8C1PShuffleCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 4,
+        2: 4,
+    }
+    b_connectors = {
+        1: 1,
+        2: 1,
+        3: 1,
+        4: 1,
+        5: 1,
+        6: 1,
+        7: 1,
+        8: 1,
+    }
+    _a_mapping = {
+        (1, 1): (1, 1),
+        (1, 2): (2, 1),
+        (1, 3): (5, 1),
+        (1, 4): (6, 1),
+        (2, 1): (3, 1),
+        (2, 2): (4, 1),
+        (2, 3): (7, 1),
+        (2, 4): (8, 1),
+    }
+    _b_mapping = {
+        (1, 1): (1, 1),
+        (2, 1): (1, 2),
+        (3, 1): (2, 1),
+        (4, 1): (2, 2),
+        (5, 1): (1, 3),
+        (6, 1): (1, 4),
+        (7, 1): (2, 3),
+        (8, 1): (2, 4),
     }
-    # B side to A side position mapping (reverse of _a_mapping)
-    _b_mapping = {v: k for k, v in _a_mapping.items()}
 
-    def get_mapped_position(self, side, position):
-        if side.lower() == 'b':
-            return self._b_mapping.get(position)
-        return self._a_mapping.get(position)
+    def get_mapped_position(self, side, connector, position):
+        if side.upper() == CableEndChoices.SIDE_A:
+            return self._a_mapping.get((connector, position))
+        return self._b_mapping.get((connector, position))

+ 66 - 8
netbox/dcim/choices.py

@@ -1722,16 +1722,74 @@ class PortTypeChoices(ChoiceSet):
 #
 
 class CableProfileChoices(ChoiceSet):
-    STRAIGHT_SINGLE = 'straight-single'
-    STRAIGHT_MULTI = 'straight-multi'
-    SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8'
-    SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8'
+    # Singles
+    SINGLE_1C1P = 'single-1c1p'
+    SINGLE_1C2P = 'single-1c2p'
+    SINGLE_1C4P = 'single-1c4p'
+    SINGLE_1C6P = 'single-1c6p'
+    SINGLE_1C8P = 'single-1c8p'
+    SINGLE_1C12P = 'single-1c12p'
+    SINGLE_1C16P = 'single-1c16p'
+    # Trunks
+    TRUNK_2C1P = 'trunk-2c1p'
+    TRUNK_2C2P = 'trunk-2c2p'
+    TRUNK_2C4P = 'trunk-2c4p'
+    TRUNK_2C4P_SHUFFLE = 'trunk-2c4p-shuffle'
+    TRUNK_2C6P = 'trunk-2c6p'
+    TRUNK_2C8P = 'trunk-2c8p'
+    TRUNK_2C12P = 'trunk-2c12p'
+    TRUNK_4C1P = 'trunk-4c1p'
+    TRUNK_4C2P = 'trunk-4c2p'
+    TRUNK_4C4P = 'trunk-4c4p'
+    TRUNK_4C4P_SHUFFLE = 'trunk-4c4p-shuffle'
+    TRUNK_4C6P = 'trunk-4c6p'
+    TRUNK_4C8P = 'trunk-4c8p'
+    TRUNK_8C4P = 'trunk-8c4p'
+    # Breakouts
+    BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p'
+    BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p'
+    BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle'
 
     CHOICES = (
-        (STRAIGHT_SINGLE, _('Straight (single position)')),
-        (STRAIGHT_MULTI, _('Straight (multi-position)')),
-        (SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')),
-        (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')),
+        (
+            _('Single'),
+            (
+                (SINGLE_1C1P, _('1C1P')),
+                (SINGLE_1C2P, _('1C2P')),
+                (SINGLE_1C4P, _('1C4P')),
+                (SINGLE_1C6P, _('1C6P')),
+                (SINGLE_1C8P, _('1C8P')),
+                (SINGLE_1C12P, _('1C12P')),
+                (SINGLE_1C16P, _('1C16P')),
+            ),
+        ),
+        (
+            _('Trunk'),
+            (
+                (TRUNK_2C1P, _('2C1P trunk')),
+                (TRUNK_2C2P, _('2C2P trunk')),
+                (TRUNK_2C4P, _('2C4P trunk')),
+                (TRUNK_2C4P_SHUFFLE, _('2C4P trunk (shuffle)')),
+                (TRUNK_2C6P, _('2C6P trunk')),
+                (TRUNK_2C8P, _('2C8P trunk')),
+                (TRUNK_2C12P, _('2C12P trunk')),
+                (TRUNK_4C1P, _('4C1P trunk')),
+                (TRUNK_4C2P, _('4C2P trunk')),
+                (TRUNK_4C4P, _('4C4P trunk')),
+                (TRUNK_4C4P_SHUFFLE, _('4C4P trunk (shuffle)')),
+                (TRUNK_4C6P, _('4C6P trunk')),
+                (TRUNK_4C8P, _('4C8P trunk')),
+                (TRUNK_8C4P, _('8C4P trunk')),
+            ),
+        ),
+        (
+            _('Breakout'),
+            (
+                (BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
+                (BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
+                (BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),
+            ),
+        ),
     )
 
 

+ 3 - 0
netbox/dcim/constants.py

@@ -24,6 +24,9 @@ RACK_STARTING_UNIT_DEFAULT = 1
 # Cables
 #
 
+CABLE_CONNECTOR_MIN = 1
+CABLE_CONNECTOR_MAX = 256
+
 CABLE_POSITION_MIN = 1
 CABLE_POSITION_MAX = 1024
 

+ 13 - 9
netbox/dcim/filtersets.py

@@ -1748,7 +1748,9 @@ class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
 
     class Meta:
         model = ConsolePort
-        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
+        fields = (
+            'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
+        )
 
 
 @register_filterset
@@ -1760,7 +1762,9 @@ class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFi
 
     class Meta:
         model = ConsoleServerPort
-        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
+        fields = (
+            'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
+        )
 
 
 @register_filterset
@@ -1774,7 +1778,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet,
         model = PowerPort
         fields = (
             'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
-            'cable_position',
+            'cable_connector',
         )
 
 
@@ -1801,7 +1805,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
         model = PowerOutlet
         fields = (
             'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
-            'cable_position',
+            'cable_connector',
         )
 
 
@@ -2111,7 +2115,7 @@ class InterfaceFilterSet(
         fields = (
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
             'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
-            'cable_id', 'cable_end', 'cable_position',
+            'cable_id', 'cable_end', 'cable_connector',
         )
 
     def filter_virtual_chassis_member_or_master(self, queryset, name, value):
@@ -2167,7 +2171,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
         model = FrontPort
         fields = (
             'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
-            'cable_position',
+            'cable_connector',
         )
 
 
@@ -2188,7 +2192,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
         model = RearPort
         fields = (
             'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
-            'cable_position',
+            'cable_connector',
         )
 
 
@@ -2544,7 +2548,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
 
     class Meta:
         model = CableTermination
-        fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id')
+        fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
 
 
 @register_filterset
@@ -2663,7 +2667,7 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpo
         model = PowerFeed
         fields = (
             'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
-            'available_power', 'mark_connected', 'cable_end', 'cable_position', 'description',
+            'available_power', 'mark_connected', 'cable_end', 'cable_connector', 'description',
         )
 
     def search(self, queryset, name, value):

+ 23 - 7
netbox/dcim/migrations/0220_cable_profile.py

@@ -1,3 +1,4 @@
+import django.contrib.postgres.fields
 import django.core.validators
 from django.db import migrations, models
 
@@ -16,25 +17,40 @@ class Migration(migrations.Migration):
         ),
         migrations.AddField(
             model_name='cabletermination',
-            name='position',
-            field=models.PositiveIntegerField(
+            name='connector',
+            field=models.PositiveSmallIntegerField(
                 blank=True,
                 null=True,
                 validators=[
                     django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
+                    django.core.validators.MaxValueValidator(256)
+                ]
+            ),
+        ),
+        migrations.AddField(
+            model_name='cabletermination',
+            name='positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024)
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None
             ),
         ),
         migrations.AlterModelOptions(
             name='cabletermination',
-            options={'ordering': ('cable', 'cable_end', 'position', 'pk')},
+            options={'ordering': ('cable', 'cable_end', 'connector', 'pk')},  # connector may be null
         ),
         migrations.AddConstraint(
             model_name='cabletermination',
             constraint=models.UniqueConstraint(
-                fields=('cable', 'cable_end', 'position'),
-                name='dcim_cabletermination_unique_position'
+                fields=('cable', 'cable_end', 'connector'),
+                name='dcim_cabletermination_unique_connector'
             ),
         ),
     ]

+ 228 - 0
netbox/dcim/migrations/0221_cable_connector_positions.py

@@ -0,0 +1,228 @@
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0220_cable_profile'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='consoleport',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='cable_connector',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(256)
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='cable_positions',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveSmallIntegerField(
+                    validators=[
+                        django.core.validators.MinValueValidator(1),
+                        django.core.validators.MaxValueValidator(1024),
+                    ]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+            ),
+        ),
+    ]

+ 0 - 107
netbox/dcim/migrations/0221_cable_position.py

@@ -1,107 +0,0 @@
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ('dcim', '0220_cable_profile'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='consoleport',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-        migrations.AddField(
-            model_name='consoleserverport',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-        migrations.AddField(
-            model_name='frontport',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-        migrations.AddField(
-            model_name='interface',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-        migrations.AddField(
-            model_name='powerfeed',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-        migrations.AddField(
-            model_name='poweroutlet',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-        migrations.AddField(
-            model_name='powerport',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-        migrations.AddField(
-            model_name='rearport',
-            name='cable_position',
-            field=models.PositiveIntegerField(
-                blank=True,
-                null=True,
-                validators=[
-                    django.core.validators.MinValueValidator(1),
-                    django.core.validators.MaxValueValidator(1024),
-                ],
-            ),
-        ),
-    ]

+ 1 - 1
netbox/dcim/migrations/0222_port_mappings.py

@@ -59,7 +59,7 @@ def populate_port_mappings(apps, schema_editor):
 
 class Migration(migrations.Migration):
     dependencies = [
-        ('dcim', '0221_cable_position'),
+        ('dcim', '0221_cable_connector_positions'),
     ]
 
     operations = [

+ 73 - 26
netbox/dcim/models/cables.py

@@ -3,6 +3,7 @@ import logging
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -136,10 +137,30 @@ class Cable(PrimaryModel):
     def profile_class(self):
         from dcim import cable_profiles
         return {
-            CableProfileChoices.STRAIGHT_SINGLE: cable_profiles.StraightSingleCableProfile,
-            CableProfileChoices.STRAIGHT_MULTI: cable_profiles.StraightMultiCableProfile,
-            CableProfileChoices.SHUFFLE_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile,
-            CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile,
+            CableProfileChoices.SINGLE_1C1P: cable_profiles.Single1C1PCableProfile,
+            CableProfileChoices.SINGLE_1C2P: cable_profiles.Single1C2PCableProfile,
+            CableProfileChoices.SINGLE_1C4P: cable_profiles.Single1C4PCableProfile,
+            CableProfileChoices.SINGLE_1C6P: cable_profiles.Single1C6PCableProfile,
+            CableProfileChoices.SINGLE_1C8P: cable_profiles.Single1C8PCableProfile,
+            CableProfileChoices.SINGLE_1C12P: cable_profiles.Single1C12PCableProfile,
+            CableProfileChoices.SINGLE_1C16P: cable_profiles.Single1C16PCableProfile,
+            CableProfileChoices.TRUNK_2C1P: cable_profiles.Trunk2C1PCableProfile,
+            CableProfileChoices.TRUNK_2C2P: cable_profiles.Trunk2C2PCableProfile,
+            CableProfileChoices.TRUNK_2C4P: cable_profiles.Trunk2C4PCableProfile,
+            CableProfileChoices.TRUNK_2C4P_SHUFFLE: cable_profiles.Trunk2C4PShuffleCableProfile,
+            CableProfileChoices.TRUNK_2C6P: cable_profiles.Trunk2C6PCableProfile,
+            CableProfileChoices.TRUNK_2C8P: cable_profiles.Trunk2C8PCableProfile,
+            CableProfileChoices.TRUNK_2C12P: cable_profiles.Trunk2C12PCableProfile,
+            CableProfileChoices.TRUNK_4C1P: cable_profiles.Trunk4C1PCableProfile,
+            CableProfileChoices.TRUNK_4C2P: cable_profiles.Trunk4C2PCableProfile,
+            CableProfileChoices.TRUNK_4C4P: cable_profiles.Trunk4C4PCableProfile,
+            CableProfileChoices.TRUNK_4C4P_SHUFFLE: cable_profiles.Trunk4C4PShuffleCableProfile,
+            CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
+            CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
+            CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
+            CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile,
+            CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile,
+            CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile,
         }.get(self.profile)
 
     def _get_x_terminations(self, side):
@@ -338,14 +359,33 @@ class Cable(PrimaryModel):
                 ct.delete()
 
         # Save any new CableTerminations
+        profile = self.profile_class() if self.profile else None
         for i, termination in enumerate(self.a_terminations, start=1):
             if not termination.pk or termination not in a_terminations:
-                position = i if self.profile and isinstance(termination, PathEndpoint) else None
-                CableTermination(cable=self, cable_end='A', position=position, termination=termination).save()
+                connector = positions = None
+                if profile:
+                    connector = i
+                    positions = profile.get_position_list(profile.a_connectors[i])
+                CableTermination(
+                    cable=self,
+                    cable_end=CableEndChoices.SIDE_A,
+                    connector=connector,
+                    positions=positions,
+                    termination=termination
+                ).save()
         for i, termination in enumerate(self.b_terminations, start=1):
             if not termination.pk or termination not in b_terminations:
-                position = i if self.profile and isinstance(termination, PathEndpoint) else None
-                CableTermination(cable=self, cable_end='B', position=position, termination=termination).save()
+                connector = positions = None
+                if profile:
+                    connector = i
+                    positions = profile.get_position_list(profile.b_connectors[i])
+                CableTermination(
+                    cable=self,
+                    cable_end=CableEndChoices.SIDE_B,
+                    connector=connector,
+                    positions=positions,
+                    termination=termination
+                ).save()
 
 
 class CableTermination(ChangeLoggedModel):
@@ -372,13 +412,23 @@ class CableTermination(ChangeLoggedModel):
         ct_field='termination_type',
         fk_field='termination_id'
     )
-    position = models.PositiveIntegerField(
+    connector = models.PositiveSmallIntegerField(
         blank=True,
         null=True,
         validators=(
-            MinValueValidator(CABLE_POSITION_MIN),
-            MaxValueValidator(CABLE_POSITION_MAX)
-        )
+            MinValueValidator(CABLE_CONNECTOR_MIN),
+            MaxValueValidator(CABLE_CONNECTOR_MAX)
+        ),
+    )
+    positions = ArrayField(
+        base_field=models.PositiveSmallIntegerField(
+            validators=(
+                MinValueValidator(CABLE_POSITION_MIN),
+                MaxValueValidator(CABLE_POSITION_MAX)
+            )
+        ),
+        blank=True,
+        null=True,
     )
 
     # Cached associations to enable efficient filtering
@@ -410,15 +460,15 @@ class CableTermination(ChangeLoggedModel):
     objects = RestrictedQuerySet.as_manager()
 
     class Meta:
-        ordering = ('cable', 'cable_end', 'position', 'pk')
+        ordering = ('cable', 'cable_end', 'connector', 'pk')
         constraints = (
             models.UniqueConstraint(
                 fields=('termination_type', 'termination_id'),
                 name='%(app_label)s_%(class)s_unique_termination'
             ),
             models.UniqueConstraint(
-                fields=('cable', 'cable_end', 'position'),
-                name='%(app_label)s_%(class)s_unique_position'
+                fields=('cable', 'cable_end', 'connector'),
+                name='%(app_label)s_%(class)s_unique_connector'
             ),
         )
         verbose_name = _('cable termination')
@@ -481,9 +531,7 @@ class CableTermination(ChangeLoggedModel):
         # Set the cable on the terminating object
         termination = self.termination._meta.model.objects.get(pk=self.termination_id)
         termination.snapshot()
-        termination.cable = self.cable
-        termination.cable_end = self.cable_end
-        termination.cable_position = self.position
+        termination.set_cable_termination(self)
         termination.save()
 
     def delete(self, *args, **kwargs):
@@ -491,9 +539,7 @@ class CableTermination(ChangeLoggedModel):
         # Delete the cable association on the terminating object
         termination = self.termination._meta.model.objects.get(pk=self.termination_id)
         termination.snapshot()
-        termination.cable = None
-        termination.cable_end = None
-        termination.cable_position = None
+        termination.clear_cable_termination(self)
         termination.save()
 
         super().delete(*args, **kwargs)
@@ -701,9 +747,9 @@ class CablePath(models.Model):
             path.append([
                 object_to_path_node(t) for t in terminations
             ])
-            # If not null, push cable_position onto the stack
-            if terminations[0].cable_position is not None:
-                position_stack.append([terminations[0].cable_position])
+            # If not null, push cable position onto the stack
+            if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions:
+                position_stack.append([terminations[0].cable_positions[0]])
 
             # Step 2: Determine the attached links (Cable or WirelessLink), if any
             links = list(dict.fromkeys(
@@ -744,8 +790,9 @@ class CablePath(models.Model):
                 # Profile-based tracing
                 if links[0].profile:
                     cable_profile = links[0].profile_class()
-                    peer_cable_terminations = cable_profile.get_peer_terminations(terminations, position_stack)
-                    remote_terminations = [ct.termination for ct in peer_cable_terminations]
+                    term, position = cable_profile.get_peer_termination(terminations[0], position_stack.pop()[0])
+                    remote_terminations = [term]
+                    position_stack.append([position])
 
                 # Legacy (positionless) behavior
                 else:

+ 55 - 16
netbox/dcim/models/device_components.py

@@ -1,6 +1,7 @@
 from functools import cached_property
 
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -177,15 +178,24 @@ class CabledObjectModel(models.Model):
         blank=True,
         null=True
     )
-    cable_position = models.PositiveIntegerField(
-        verbose_name=_('cable position'),
+    cable_connector = models.PositiveSmallIntegerField(
         blank=True,
         null=True,
         validators=(
-            MinValueValidator(CABLE_POSITION_MIN),
-            MaxValueValidator(CABLE_POSITION_MAX)
+            MinValueValidator(CABLE_CONNECTOR_MIN),
+            MaxValueValidator(CABLE_CONNECTOR_MAX)
         ),
     )
+    cable_positions = ArrayField(
+        base_field=models.PositiveSmallIntegerField(
+            validators=(
+                MinValueValidator(CABLE_POSITION_MIN),
+                MaxValueValidator(CABLE_POSITION_MAX)
+            )
+        ),
+        blank=True,
+        null=True,
+    )
     mark_connected = models.BooleanField(
         verbose_name=_('mark connected'),
         default=False,
@@ -210,18 +220,31 @@ class CabledObjectModel(models.Model):
                 raise ValidationError({
                     "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
                 })
-        if self.cable_end and not self.cable:
-            raise ValidationError({
-                "cable_end": _("Cable end must not be set without a cable.")
-            })
-        if self.cable_position and not self.cable:
-            raise ValidationError({
-                "cable_position": _("Cable termination position must not be set without a cable.")
-            })
-        if self.mark_connected and self.cable:
-            raise ValidationError({
-                "mark_connected": _("Cannot mark as connected with a cable attached.")
-            })
+            if self.cable_connector and not self.cable_positions:
+                raise ValidationError({
+                    "cable_positions": _("Must specify position(s) when specifying a cable connector.")
+                })
+            if self.cable_positions and not self.cable_connector:
+                raise ValidationError({
+                    "cable_positions": _("Cable positions cannot be set without a cable connector.")
+                })
+            if self.mark_connected:
+                raise ValidationError({
+                    "mark_connected": _("Cannot mark as connected with a cable attached.")
+                })
+        else:
+            if self.cable_end:
+                raise ValidationError({
+                    "cable_end": _("Cable end must not be set without a cable.")
+                })
+            if self.cable_connector:
+                raise ValidationError({
+                    "cable_connector": _("Cable connector must not be set without a cable.")
+                })
+            if self.cable_positions:
+                raise ValidationError({
+                    "cable_positions": _("Cable termination positions must not be set without a cable.")
+                })
 
     @property
     def link(self):
@@ -256,6 +279,22 @@ class CabledObjectModel(models.Model):
             return None
         return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
 
+    def set_cable_termination(self, termination):
+        """Save attributes from the given CableTermination on the terminating object."""
+        self.cable = termination.cable
+        self.cable_end = termination.cable_end
+        self.cable_connector = termination.connector
+        self.cable_positions = termination.positions
+    set_cable_termination.alters_data = True
+
+    def clear_cable_termination(self, termination):
+        """Clear all cable termination attributes from the terminating object."""
+        self.cable = None
+        self.cable_end = None
+        self.cable_connector = None
+        self.cable_positions = None
+    clear_cable_termination.alters_data = True
+
 
 class PathEndpoint(models.Model):
     """

+ 5 - 3
netbox/dcim/tests/test_api.py

@@ -2586,7 +2586,7 @@ class CableTest(APIViewTestCases.APIViewTestCase):
                     'object_id': interfaces[14].pk,
                 }],
                 'label': 'Cable 4',
-                'profile': CableProfileChoices.STRAIGHT_SINGLE,
+                'profile': CableProfileChoices.SINGLE_1C1P,
             },
             {
                 'a_terminations': [{
@@ -2598,7 +2598,7 @@ class CableTest(APIViewTestCases.APIViewTestCase):
                     'object_id': interfaces[15].pk,
                 }],
                 'label': 'Cable 5',
-                'profile': CableProfileChoices.STRAIGHT_SINGLE,
+                'profile': CableProfileChoices.SINGLE_1C1P,
             },
             {
                 'a_terminations': [{
@@ -2620,7 +2620,9 @@ class CableTerminationTest(
     APIViewTestCases.ListObjectsViewTestCase,
 ):
     model = CableTermination
-    brief_fields = ['cable', 'cable_end', 'display', 'id', 'position', 'termination_id', 'termination_type', 'url']
+    brief_fields = [
+        'cable', 'cable_end', 'connector', 'display', 'id', 'positions', 'termination_id', 'termination_type', 'url',
+    ]
 
     @classmethod
     def setUpTestData(cls):

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 696 - 200
netbox/dcim/tests/test_cablepaths2.py


+ 8 - 1
netbox/dcim/tests/test_filtersets.py

@@ -3332,6 +3332,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsolePort.objects.all()
     filterset = ConsolePortFilterSet
+    ignore_fields = ('cable_positions',)
 
     @classmethod
     def setUpTestData(cls):
@@ -3582,6 +3583,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
 class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsoleServerPort.objects.all()
     filterset = ConsoleServerPortFilterSet
+    ignore_fields = ('cable_positions',)
 
     @classmethod
     def setUpTestData(cls):
@@ -3832,6 +3834,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
 class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = PowerPort.objects.all()
     filterset = PowerPortFilterSet
+    ignore_fields = ('cable_positions',)
 
     @classmethod
     def setUpTestData(cls):
@@ -4096,6 +4099,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
 class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = PowerOutlet.objects.all()
     filterset = PowerOutletFilterSet
+    ignore_fields = ('cable_positions',)
 
     @classmethod
     def setUpTestData(cls):
@@ -4380,7 +4384,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
-    ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan', 'vdcs')
+    ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan', 'vdcs', 'cable_positions')
 
     @classmethod
     def setUpTestData(cls):
@@ -5017,6 +5021,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
 class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = FrontPort.objects.all()
     filterset = FrontPortFilterSet
+    ignore_fields = ('cable_positions',)
 
     @classmethod
     def setUpTestData(cls):
@@ -5321,6 +5326,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
 class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = RearPort.objects.all()
     filterset = RearPortFilterSet
+    ignore_fields = ('cable_positions',)
 
     @classmethod
     def setUpTestData(cls):
@@ -6859,6 +6865,7 @@ class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
 class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerFeed.objects.all()
     filterset = PowerFeedFilterSet
+    ignore_fields = ('cable_positions',)
 
     @classmethod
     def setUpTestData(cls):

+ 3 - 3
netbox/dcim/utils.py

@@ -41,12 +41,12 @@ def create_cablepaths(objects):
     """
     from dcim.models import CablePath
 
-    # Arrange objects by cable position. All objects with a null position are grouped together.
+    # Arrange objects by cable connector. All objects with a null connector are grouped together.
     origins = defaultdict(list)
     for obj in objects:
-        origins[obj.cable_position].append(obj)
+        origins[obj.cable_connector].append(obj)
 
-    for position, objects in origins.items():
+    for connector, objects in origins.items():
         if cp := CablePath.from_origin(objects):
             cp.save()
 

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio