From aaa05fe0719200d095f07f69c886958064721222 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 11 Dec 2025 16:33:30 -0500 Subject: [PATCH 1/6] #20788: Map positions by connector (WIP) --- netbox/circuits/filtersets.py | 2 +- .../migrations/0054_cable_position.py | 22 +- netbox/dcim/cable_profiles.py | 303 +++++++++++++----- netbox/dcim/choices.py | 24 +- netbox/dcim/constants.py | 3 + netbox/dcim/filtersets.py | 14 +- netbox/dcim/migrations/0220_cable_profile.py | 30 +- netbox/dcim/migrations/0221_cable_position.py | 175 ++++++++-- netbox/dcim/models/cables.py | 79 +++-- netbox/dcim/models/device_components.py | 18 +- netbox/dcim/tests/test_api.py | 4 +- netbox/dcim/tests/test_cablepaths2.py | 201 ++++++++---- netbox/dcim/utils.py | 6 +- 13 files changed, 650 insertions(+), 231 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index faf29584f..c98defd2f 100644 --- a/netbox/circuits/filtersets.py +++ b/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', ) def search(self, queryset, name, value): diff --git a/netbox/circuits/migrations/0054_cable_position.py b/netbox/circuits/migrations/0054_cable_position.py index cedc8813b..12e0e2fbf 100644 --- a/netbox/circuits/migrations/0054_cable_position.py +++ b/netbox/circuits/migrations/0054_cable_position.py @@ -1,3 +1,4 @@ +import django.contrib.postgres.fields import django.core.validators from django.db import migrations, models @@ -10,14 +11,29 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='circuittermination', - name='cable_position', - field=models.PositiveIntegerField( + name='cable_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='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, + ), + ), ] diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 4251cd4d9..14200cca2 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -1,108 +1,247 @@ -from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ - from dcim.models import CableTermination class BaseCableProfile: - # Maximum number of terminations allowed per side - a_max_connections = None - b_max_connections = None def clean(self, cable): - # Enforce maximum connection limits - if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections: - raise ValidationError({ - 'a_terminations': _( - 'Maximum A side connections for profile {profile}: {max}' - ).format( - profile=cable.get_profile_display(), - max=self.a_max_connections, - ) - }) - if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections: - raise ValidationError({ - 'b_terminations': _( - 'Maximum B side connections for profile {profile}: {max}' - ).format( - profile=cable.get_profile_display(), - max=self.b_max_connections, - ) - }) + pass + # # Enforce maximum connection limits + # if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections: + # raise ValidationError({ + # 'a_terminations': _( + # 'Maximum A side connections for profile {profile}: {max}' + # ).format( + # profile=cable.get_profile_display(), + # max=self.a_max_connections, + # ) + # }) + # if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections: + # raise ValidationError({ + # 'b_terminations': _( + # 'Maximum B side connections for profile {profile}: {max}' + # ).format( + # profile=cable.get_profile_display(), + # max=self.b_max_connections, + # ) + # }) - def get_mapped_position(self, side, position): + def get_mapped_position(self, side, connector, position): """ - Return the mapped position for a given cable end and position. - - By default, assume all positions are symmetrical. + Return the mapped far-end connector & position for a given cable end the local connector & position. """ - return position + # By default, assume all positions are symmetrical. + return connector, position - 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 + def get_peer_termination(self, termination, position): + """ + Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable. + """ + print(f'get_peer_termination({termination}, {position})') + print(f' Mapping {termination.cable_end} {termination.cable_connector}:{position}...') + connector, position = self.get_mapped_position( + termination.cable_end, + termination.cable_connector, + position ) - - # 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) + print(f' Mapped to {connector}:{position}') + try: + ct = CableTermination.objects.get( + cable=termination.cable, + cable_end=termination.opposite_cable_end, + connector=connector, + positions__contains=[position], + ) + print(f' Found termination {ct.termination}') + return ct.termination, position + except CableTermination.DoesNotExist: + print(f' Failed to resolve far end termination for {connector}:{position}') + return None, None -class StraightSingleCableProfile(BaseCableProfile): - a_max_connections = 1 - b_max_connections = 1 +class Straight1C1PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1], + } + b_connectors = a_connectors -class StraightMultiCableProfile(BaseCableProfile): - a_max_connections = None - b_max_connections = None +class Straight1C2PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2], + } + b_connectors = a_connectors + + +class Straight1C4PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4], + } + b_connectors = a_connectors + + +class Straight1C8PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6, 7, 8], + } + b_connectors = a_connectors + + +class Straight2C1PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1], + 2: [1], + } + b_connectors = a_connectors + + +class Straight2C2PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2], + 2: [1, 2], + } + b_connectors = a_connectors + + +class Breakout1x4CableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 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), + } + + def get_mapped_position(self, side, connector, position): + return self._mapping.get((connector, position)) + + +class MPOTrunk4x4CableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 4], + 3: [1, 2, 3, 4], + 4: [1, 2, 3, 4], + } + b_connectors = a_connectors + + +class MPOTrunk8x8CableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 4], + 3: [1, 2, 3, 4], + 4: [1, 2, 3, 4], + 5: [1, 2, 3, 4], + 6: [1, 2, 3, 4], + 7: [1, 2, 3, 4], + 8: [1, 2, 3, 4], + } + b_connectors = a_connectors class Shuffle2x2MPO8CableProfile(BaseCableProfile): - a_max_connections = 8 - b_max_connections = 8 + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 4], + } + b_connectors = a_connectors _mapping = { - 1: 1, - 2: 2, - 3: 5, - 4: 6, - 5: 3, - 6: 4, - 7: 7, - 8: 8, + (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), } - def get_mapped_position(self, side, position): - return self._mapping.get(position) + def get_mapped_position(self, side, connector, position): + return self._mapping.get((connector, position)) class Shuffle4x4MPO8CableProfile(BaseCableProfile): - a_max_connections = 8 - b_max_connections = 8 - # A side to B side position mapping - _a_mapping = { - 1: 1, - 2: 3, - 3: 5, - 4: 7, - 5: 2, - 6: 4, - 7: 6, - 8: 8, + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 4], + 3: [1, 2, 3, 4], + 4: [1, 2, 3, 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), } - # 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): + return self._mapping.get((connector, position)) + + +class ShuffleBreakout2x8CableProfile(BaseCableProfile): + """ + Temporary solution for mapping 2 front/rear ports to 8 discrete interfaces + """ + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 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, 2), + (2, 2): (4, 2), + (2, 3): (7, 2), + (2, 4): (8, 2), + } + _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), + } + + def get_mapped_position(self, side, connector, position): + if side.lower() == 'a': + return self._a_mapping.get((connector, position)) + return self._b_mapping.get((connector, position)) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2aecb0907..98225d276 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1722,16 +1722,32 @@ class PortTypeChoices(ChoiceSet): # class CableProfileChoices(ChoiceSet): - STRAIGHT_SINGLE = 'straight-single' - STRAIGHT_MULTI = 'straight-multi' + STRAIGHT_1C1P = 'straight-1c1p' + STRAIGHT_1C2P = 'straight-1c2p' + STRAIGHT_1C4P = 'straight-1c4p' + STRAIGHT_1C8P = 'straight-1c8p' + STRAIGHT_2C1P = 'straight-2c1p' + STRAIGHT_2C2P = 'straight-2c2p' + BREAKOUT_1X4 = 'breakout-1x4' + MPO_TRUNK_4X4 = 'mpo-trunk-4x4' + MPO_TRUNK_8X8 = 'mpo-trunk-8x8' SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8' SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8' + SHUFFLE_BREAKOUT_2X8 = 'shuffle-breakout-2x8' CHOICES = ( - (STRAIGHT_SINGLE, _('Straight (single position)')), - (STRAIGHT_MULTI, _('Straight (multi-position)')), + (STRAIGHT_1C1P, _('Straight (1C1P)')), + (STRAIGHT_1C2P, _('Straight (1C2P)')), + (STRAIGHT_1C4P, _('Straight (1C4P)')), + (STRAIGHT_1C8P, _('Straight (1C8P)')), + (STRAIGHT_2C1P, _('Straight (2C1P)')), + (STRAIGHT_2C2P, _('Straight (2C2P)')), + (BREAKOUT_1X4, _('Breakout (1:4)')), + (MPO_TRUNK_4X4, _('MPO Trunk (4x4)')), + (MPO_TRUNK_8X8, _('MPO Trunk (8x8)')), (SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')), (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')), + (SHUFFLE_BREAKOUT_2X8, _('Shuffle breakout (2x8)')), ) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 16926081f..669345d7c 100644 --- a/netbox/dcim/constants.py +++ b/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 diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index cf37b6802..ef5527418 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1748,7 +1748,7 @@ 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') @register_filterset @@ -1760,7 +1760,7 @@ 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') @register_filterset @@ -1774,7 +1774,6 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, model = PowerPort fields = ( 'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end', - 'cable_position', ) @@ -1801,7 +1800,6 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe model = PowerOutlet fields = ( 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', - 'cable_position', ) @@ -2111,7 +2109,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', ) def filter_virtual_chassis_member_or_master(self, queryset, name, value): @@ -2167,7 +2165,6 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) model = FrontPort fields = ( 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', - 'cable_position', ) @@ -2188,7 +2185,6 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): model = RearPort fields = ( 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', - 'cable_position', ) @@ -2544,7 +2540,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 +2659,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', 'description', ) def search(self, queryset, name, value): diff --git a/netbox/dcim/migrations/0220_cable_profile.py b/netbox/dcim/migrations/0220_cable_profile.py index d97ae8df8..f196707c5 100644 --- a/netbox/dcim/migrations/0220_cable_profile.py +++ b/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', 'positions', 'pk')}, ), 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' ), ), ] diff --git a/netbox/dcim/migrations/0221_cable_position.py b/netbox/dcim/migrations/0221_cable_position.py index 0c6a2ce5d..e986a28e1 100644 --- a/netbox/dcim/migrations/0221_cable_position.py +++ b/netbox/dcim/migrations/0221_cable_position.py @@ -1,3 +1,4 @@ +import django.contrib.postgres.fields import django.core.validators from django.db import migrations, models @@ -10,98 +11,218 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='consoleport', - name='cable_position', - field=models.PositiveIntegerField( + name='cable_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='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_position', - field=models.PositiveIntegerField( + 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(1024), + django.core.validators.MaxValueValidator(256) ], ), ), migrations.AddField( model_name='frontport', - name='cable_position', - field=models.PositiveIntegerField( + 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(1024), + django.core.validators.MaxValueValidator(256) ], ), ), migrations.AddField( model_name='interface', - name='cable_position', - field=models.PositiveIntegerField( + 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(1024), + django.core.validators.MaxValueValidator(256) ], ), ), migrations.AddField( model_name='powerfeed', - name='cable_position', - field=models.PositiveIntegerField( + 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(1024), + django.core.validators.MaxValueValidator(256) ], ), ), migrations.AddField( model_name='poweroutlet', - name='cable_position', - field=models.PositiveIntegerField( + 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(1024), + django.core.validators.MaxValueValidator(256) ], ), ), migrations.AddField( model_name='powerport', - name='cable_position', - field=models.PositiveIntegerField( + 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(1024), + django.core.validators.MaxValueValidator(256) ], ), ), migrations.AddField( model_name='rearport', - name='cable_position', - field=models.PositiveIntegerField( + 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, - validators=[ - django.core.validators.MinValueValidator(1), - django.core.validators.MaxValueValidator(1024), - ], + size=None, ), ), ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e75b4c110..823198908 100644 --- a/netbox/dcim/models/cables.py +++ b/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,18 @@ 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.STRAIGHT_1C1P: cable_profiles.Straight1C1PCableProfile, + CableProfileChoices.STRAIGHT_1C2P: cable_profiles.Straight1C2PCableProfile, + CableProfileChoices.STRAIGHT_1C4P: cable_profiles.Straight1C4PCableProfile, + CableProfileChoices.STRAIGHT_1C8P: cable_profiles.Straight1C8PCableProfile, + CableProfileChoices.STRAIGHT_2C1P: cable_profiles.Straight2C1PCableProfile, + CableProfileChoices.STRAIGHT_2C2P: cable_profiles.Straight2C2PCableProfile, + CableProfileChoices.BREAKOUT_1X4: cable_profiles.Breakout1x4CableProfile, + CableProfileChoices.MPO_TRUNK_4X4: cable_profiles.MPOTrunk4x4CableProfile, + CableProfileChoices.MPO_TRUNK_8X8: cable_profiles.MPOTrunk8x8CableProfile, CableProfileChoices.SHUFFLE_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile, CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile, + CableProfileChoices.SHUFFLE_BREAKOUT_2X8: cable_profiles.ShuffleBreakout2x8CableProfile, }.get(self.profile) def _get_x_terminations(self, side): @@ -340,12 +349,30 @@ class Cable(PrimaryModel): # Save any new CableTerminations 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 self.profile: + connector = i + positions = self.profile_class().a_connectors[i] + CableTermination( + cable=self, + cable_end='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 self.profile: + connector = i + positions = self.profile_class().b_connectors[i] + CableTermination( + cable=self, + cable_end='B', + connector=connector, + positions=positions, + termination=termination + ).save() class CableTermination(ChangeLoggedModel): @@ -372,13 +399,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 +447,15 @@ class CableTermination(ChangeLoggedModel): objects = RestrictedQuerySet.as_manager() class Meta: - ordering = ('cable', 'cable_end', 'position', 'pk') + ordering = ('cable', 'cable_end', 'connector', 'positions', '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') @@ -483,7 +520,8 @@ class CableTermination(ChangeLoggedModel): termination.snapshot() termination.cable = self.cable termination.cable_end = self.cable_end - termination.cable_position = self.position + termination.cable_connector = self.connector + termination.cable_positions = self.positions termination.save() def delete(self, *args, **kwargs): @@ -493,6 +531,7 @@ class CableTermination(ChangeLoggedModel): termination.snapshot() termination.cable = None termination.cable_end = None + termination.cable_connector = None termination.cable_position = None termination.save() @@ -701,9 +740,10 @@ 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 + # TODO: Handle multiple positions? + 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 +784,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: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e2077e9fe..622380e30 100644 --- a/netbox/dcim/models/device_components.py +++ b/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, diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d4783bc3c..a76e9563a 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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.STRAIGHT_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.STRAIGHT_1C1P, }, { 'a_terminations': [{ diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 0f9a704c5..1aae6ddaf 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -1,3 +1,5 @@ +from unittest import skip + from circuits.models import CircuitTermination from dcim.choices import CableProfileChoices from dcim.models import * @@ -14,11 +16,11 @@ class CablePathTests(CablePathTestCase): 2XX: Topology tests replicated from the legacy test case and adapted to use profiles """ - def test_101_cable_profile_straight_single(self): + def test_101_cable_profile_straight_1c1p(self): """ [IF1] --C1-- [IF2] - Cable profile: Straight single + Cable profile: Straight 1C1P """ interfaces = [ Interface.objects.create(device=self.device, name='Interface 1'), @@ -27,7 +29,7 @@ class CablePathTests(CablePathTestCase): # Create cable 1 cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_SINGLE, + profile=CableProfileChoices.STRAIGHT_1C1P, a_terminations=[interfaces[0]], b_terminations=[interfaces[1]], ) @@ -49,8 +51,10 @@ class CablePathTests(CablePathTestCase): interfaces[1].refresh_from_db() self.assertPathIsSet(interfaces[0], path1) self.assertPathIsSet(interfaces[1], path2) - self.assertEqual(interfaces[0].cable_position, 1) - self.assertEqual(interfaces[1].cable_position, 1) + self.assertEqual(interfaces[0].cable_connector, 1) + self.assertEqual(interfaces[0].cable_positions, [1]) + self.assertEqual(interfaces[1].cable_connector, 1) + self.assertEqual(interfaces[1].cable_positions, [1]) # Test SVG generation CableTraceSVG(interfaces[0]).render() @@ -61,12 +65,12 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_102_cable_profile_straight_multi(self): + def test_102_cable_profile_straight_2c1p(self): """ [IF1] --C1-- [IF3] [IF2] [IF4] - Cable profile: Straight multi + Cable profile: Straight 2C1P """ interfaces = [ Interface.objects.create(device=self.device, name='Interface 1'), @@ -77,7 +81,7 @@ class CablePathTests(CablePathTestCase): # Create cable 1 cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, + profile=CableProfileChoices.STRAIGHT_2C1P, a_terminations=[interfaces[0], interfaces[1]], b_terminations=[interfaces[2], interfaces[3]], ) @@ -112,10 +116,14 @@ class CablePathTests(CablePathTestCase): self.assertPathIsSet(interfaces[1], path2) self.assertPathIsSet(interfaces[2], path3) self.assertPathIsSet(interfaces[3], path4) - self.assertEqual(interfaces[0].cable_position, 1) - self.assertEqual(interfaces[1].cable_position, 2) - self.assertEqual(interfaces[2].cable_position, 1) - self.assertEqual(interfaces[3].cable_position, 2) + self.assertEqual(interfaces[0].cable_connector, 1) + self.assertEqual(interfaces[0].cable_positions, [1]) + self.assertEqual(interfaces[1].cable_connector, 2) + self.assertEqual(interfaces[1].cable_positions, [1]) + self.assertEqual(interfaces[2].cable_connector, 1) + self.assertEqual(interfaces[2].cable_positions, [1]) + self.assertEqual(interfaces[3].cable_connector, 2) + self.assertEqual(interfaces[3].cable_positions, [1]) # Test SVG generation CableTraceSVG(interfaces[0]).render() @@ -126,7 +134,8 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_103_cable_profile_2x2_mpo8(self): + @skip("Under development") + def test_103_cable_profile_shuffle_2x2_mpo8(self): """ [IF1:1] --C1-- [IF3:1] [IF1:2] [IF3:2] @@ -227,7 +236,7 @@ class CablePathTests(CablePathTestCase): interface.refresh_from_db() self.assertPathIsSet(interface, path) self.assertEqual(interface.cable_end, 'A' if i < 8 else 'B') - self.assertEqual(interface.cable_position, (i % 8) + 1) + self.assertEqual(interface.cable_positions, [(i % 8) + 1]) # Test SVG generation CableTraceSVG(interfaces[0]).render() @@ -238,6 +247,7 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) + @skip("Under development") def test_104_cable_profile_4x4_mpo8(self): """ [IF1:1] --C1-- [IF3:1] @@ -339,7 +349,7 @@ class CablePathTests(CablePathTestCase): interface.refresh_from_db() self.assertPathIsSet(interface, path) self.assertEqual(interface.cable_end, 'A' if i < 8 else 'B') - self.assertEqual(interface.cable_position, (i % 8) + 1) + self.assertEqual(interface.cable_positions, [(i % 8) + 1]) # Test SVG generation CableTraceSVG(interfaces[0]).render() @@ -361,8 +371,8 @@ class CablePathTests(CablePathTestCase): Interface.objects.create(device=self.device, name='Interface 3'), Interface.objects.create(device=self.device, name='Interface 4'), ] - rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') - frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', positions=4) PortMapping.objects.bulk_create([ PortMapping( device=self.device, @@ -371,18 +381,39 @@ class CablePathTests(CablePathTestCase): rear_port=rearport1, rear_port_position=1, ), + PortMapping( + device=self.device, + front_port=frontport1, + front_port_position=2, + rear_port=rearport1, + rear_port_position=2, + ), + PortMapping( + device=self.device, + front_port=frontport1, + front_port_position=3, + rear_port=rearport1, + rear_port_position=3, + ), + PortMapping( + device=self.device, + front_port=frontport1, + front_port_position=4, + rear_port=rearport1, + rear_port_position=4, + ), ]) # Create cables cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, - a_terminations=[interfaces[0], interfaces[1]], - b_terminations=[frontport1], + profile=CableProfileChoices.BREAKOUT_1X4, + a_terminations=[frontport1], + b_terminations=[interfaces[0], interfaces[1]], ) cable1.clean() cable1.save() cable2 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, + profile=CableProfileChoices.BREAKOUT_1X4, a_terminations=[rearport1], b_terminations=[interfaces[2], interfaces[3]] ) @@ -424,10 +455,10 @@ class CablePathTests(CablePathTestCase): def test_204_multiple_paths_via_pass_through_with_breakouts(self): """ - [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF4] - [IF2] [IF5] - [IF3] --C2-- [FP1:2] [FP2:2] --C5-- [IF6] - [IF4] [IF7] + [IF1] --C1-- [FP1] [RP1] --C3-- [RP2] [FP3] --C4-- [IF5] + [IF2] [IF6] + [IF3] --C2-- [FP2] [FP4] --C5-- [IF7] + [IF4] [IF8] """ interfaces = [ Interface.objects.create(device=self.device, name='Interface 1'), @@ -439,76 +470,104 @@ class CablePathTests(CablePathTestCase): Interface.objects.create(device=self.device, name='Interface 7'), Interface.objects.create(device=self.device, name='Interface 8'), ] - rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) - rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) - frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') - frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') - frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') - frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=8) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=8) + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1', positions=4) + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2', positions=4) + frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 2:1', positions=4) + frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 2:2', positions=4) PortMapping.objects.bulk_create([ PortMapping( device=self.device, - front_port=frontport1_1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortMapping( device=self.device, - front_port=frontport1_2, - front_port_position=1, + front_port=frontport1, + front_port_position=2, rear_port=rearport1, rear_port_position=2, ), PortMapping( device=self.device, - front_port=frontport2_1, + front_port=frontport2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=5, + ), + PortMapping( + device=self.device, + front_port=frontport2, + front_port_position=2, + rear_port=rearport1, + rear_port_position=6, + ), + PortMapping( + device=self.device, + front_port=frontport3, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortMapping( device=self.device, - front_port=frontport2_2, - front_port_position=1, + front_port=frontport3, + front_port_position=2, rear_port=rearport2, rear_port_position=2, ), + PortMapping( + device=self.device, + front_port=frontport4, + front_port_position=1, + rear_port=rearport2, + rear_port_position=5, + ), + PortMapping( + device=self.device, + front_port=frontport4, + front_port_position=2, + rear_port=rearport2, + rear_port_position=6, + ), ]) # Create cables cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, - a_terminations=[interfaces[0], interfaces[1]], - b_terminations=[frontport1_1] + profile=CableProfileChoices.BREAKOUT_1X4, + a_terminations=[frontport1], + b_terminations=[interfaces[0], interfaces[1]], ) cable1.clean() cable1.save() cable2 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, - a_terminations=[interfaces[2], interfaces[3]], - b_terminations=[frontport1_2] + profile=CableProfileChoices.BREAKOUT_1X4, + a_terminations=[frontport2], + b_terminations=[interfaces[2], interfaces[3]], ) cable2.clean() cable2.save() cable3 = Cable( - profile=CableProfileChoices.STRAIGHT_SINGLE, + profile=CableProfileChoices.STRAIGHT_1C8P, a_terminations=[rearport1], b_terminations=[rearport2] ) cable3.clean() cable3.save() cable4 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, - a_terminations=[frontport2_1], - b_terminations=[interfaces[4], interfaces[5]] + profile=CableProfileChoices.BREAKOUT_1X4, + a_terminations=[frontport3], + b_terminations=[interfaces[4], interfaces[5]], ) cable4.clean() cable4.save() cable5 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, - a_terminations=[frontport2_2], - b_terminations=[interfaces[6], interfaces[7]] + profile=CableProfileChoices.BREAKOUT_1X4, + a_terminations=[frontport4], + b_terminations=[interfaces[6], interfaces[7]], ) cable5.clean() cable5.save() @@ -516,7 +575,7 @@ class CablePathTests(CablePathTestCase): paths = [ self.assertPathExists( ( - interfaces[0], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, + interfaces[0], cable1, frontport1, rearport1, cable3, rearport2, frontport3, cable4, interfaces[4], ), is_complete=True, @@ -524,7 +583,7 @@ class CablePathTests(CablePathTestCase): ), self.assertPathExists( ( - interfaces[1], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, + interfaces[1], cable1, frontport1, rearport1, cable3, rearport2, frontport3, cable4, interfaces[5], ), is_complete=True, @@ -532,7 +591,7 @@ class CablePathTests(CablePathTestCase): ), self.assertPathExists( ( - interfaces[2], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, + interfaces[2], cable2, frontport2, rearport1, cable3, rearport2, frontport4, cable5, interfaces[6], ), is_complete=True, @@ -540,7 +599,7 @@ class CablePathTests(CablePathTestCase): ), self.assertPathExists( ( - interfaces[3], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, + interfaces[3], cable2, frontport2, rearport1, cable3, rearport2, frontport4, cable5, interfaces[7], ), is_complete=True, @@ -548,7 +607,7 @@ class CablePathTests(CablePathTestCase): ), self.assertPathExists( ( - interfaces[4], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, + interfaces[4], cable4, frontport3, rearport2, cable3, rearport1, frontport1, cable1, interfaces[0], ), is_complete=True, @@ -556,7 +615,7 @@ class CablePathTests(CablePathTestCase): ), self.assertPathExists( ( - interfaces[5], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, + interfaces[5], cable4, frontport3, rearport2, cable3, rearport1, frontport1, cable1, interfaces[1], ), is_complete=True, @@ -564,7 +623,7 @@ class CablePathTests(CablePathTestCase): ), self.assertPathExists( ( - interfaces[6], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, + interfaces[6], cable5, frontport4, rearport2, cable3, rearport1, frontport2, cable2, interfaces[2], ), is_complete=True, @@ -572,7 +631,7 @@ class CablePathTests(CablePathTestCase): ), self.assertPathExists( ( - interfaces[7], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, + interfaces[7], cable5, frontport4, rearport2, cable3, rearport1, frontport2, cable2, interfaces[3], ), is_complete=True, @@ -619,14 +678,14 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, - a_terminations=[interfaces[0], interfaces[1]], - b_terminations=[circuittermination1] + profile=CableProfileChoices.BREAKOUT_1X4, + a_terminations=[circuittermination1], + b_terminations=[interfaces[0], interfaces[1]], ) cable1.clean() cable1.save() cable2 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, + profile=CableProfileChoices.BREAKOUT_1X4, a_terminations=[circuittermination2], b_terminations=[interfaces[2], interfaces[3]] ) @@ -668,6 +727,8 @@ class CablePathTests(CablePathTestCase): # Test SVG generation CableTraceSVG(interfaces[0]).render() + # TBD: Is this a topology we want to support? + @skip("Test applicability TBD") def test_217_interface_to_interface_via_rear_ports(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP3] [FP3] --C3-- [IF2] @@ -722,7 +783,7 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, + profile=CableProfileChoices.STRAIGHT_2C1P, a_terminations=[interfaces[0]], b_terminations=[front_ports[0], front_ports[1]] ) @@ -735,7 +796,7 @@ class CablePathTests(CablePathTestCase): cable2.clean() cable2.save() cable3 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, + profile=CableProfileChoices.STRAIGHT_2C1P, a_terminations=[interfaces[1]], b_terminations=[front_ports[2], front_ports[3]] ) @@ -803,14 +864,14 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, + profile=CableProfileChoices.STRAIGHT_2C2P, a_terminations=[interfaces[0], interfaces[1]], b_terminations=[frontport1, frontport2] ) cable1.clean() cable1.save() cable2 = Cable( - profile=CableProfileChoices.STRAIGHT_MULTI, + profile=CableProfileChoices.STRAIGHT_2C2P, a_terminations=[rearport1, rearport2], b_terminations=[interfaces[2], interfaces[3]] ) @@ -819,22 +880,22 @@ class CablePathTests(CablePathTestCase): # Validate paths self.assertPathExists( - (interfaces[0], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[2]), + (interfaces[0], cable1, frontport1, rearport1, cable2, interfaces[2]), is_complete=True, is_active=True ) self.assertPathExists( - (interfaces[1], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[3]), + (interfaces[1], cable1, frontport2, rearport2, cable2, interfaces[3]), is_complete=True, is_active=True ) self.assertPathExists( - (interfaces[2], cable2, [rearport1, rearport2], [frontport1, frontport2], cable1, interfaces[0]), + (interfaces[2], cable2, rearport1, frontport1, cable1, interfaces[0]), is_complete=True, is_active=True ) self.assertPathExists( - (interfaces[3], cable2, [rearport1, rearport2], [frontport1, frontport2], cable1, interfaces[1]), + (interfaces[3], cable2, rearport2, frontport2, cable1, interfaces[1]), is_complete=True, is_active=True ) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index ce4a8c8d5..4b9d0fb5c 100644 --- a/netbox/dcim/utils.py +++ b/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() From 0ac52f3ce47a30f882f521e7d64fef0d75fe59fe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Dec 2025 10:26:01 -0500 Subject: [PATCH 2/6] Cleanup; updated tests --- netbox/circuits/filtersets.py | 2 +- netbox/circuits/tests/test_filtersets.py | 2 +- netbox/dcim/api/serializers_/cables.py | 4 +- netbox/dcim/cable_profiles.py | 53 +++++++++++++----------- netbox/dcim/filtersets.py | 16 +++++-- netbox/dcim/models/cables.py | 2 +- netbox/dcim/models/device_components.py | 4 +- netbox/dcim/tests/test_api.py | 2 +- netbox/dcim/tests/test_filtersets.py | 9 +++- 9 files changed, 56 insertions(+), 38 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index c98defd2f..6c7b45164 100644 --- a/netbox/circuits/filtersets.py +++ b/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', + 'mark_connected', 'pp_info', 'cable_end', 'cable_connector', ) def search(self, queryset, name, value): diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 91077ee64..6b6a93608 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/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): diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py index 0effbd536..ff648839b 100644 --- a/netbox/dcim/api/serializers_/cables.py +++ b/netbox/dcim/api/serializers_/cables.py @@ -61,11 +61,11 @@ class CableTerminationSerializer(NetBoxModelSerializer): model = CableTermination fields = [ 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', - 'termination', 'position', 'created', 'last_updated', + 'termination', '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', 'positions', 'termination_type', 'termination_id', ) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 14200cca2..3e7f24f60 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -1,29 +1,37 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + from dcim.models import CableTermination class BaseCableProfile: def clean(self, cable): - pass - # # Enforce maximum connection limits - # if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections: - # raise ValidationError({ - # 'a_terminations': _( - # 'Maximum A side connections for profile {profile}: {max}' - # ).format( - # profile=cable.get_profile_display(), - # max=self.a_max_connections, - # ) - # }) - # if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections: - # raise ValidationError({ - # 'b_terminations': _( - # 'Maximum B side connections for profile {profile}: {max}' - # ).format( - # profile=cable.get_profile_display(), - # max=self.b_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': _( + '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=max_a_terminations, + ) + }) + if b_terminations_count > max_b_terminations: + raise ValidationError({ + 'b_terminations': _( + '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=max_b_terminations, + ) + }) def get_mapped_position(self, side, connector, position): """ @@ -36,14 +44,11 @@ class BaseCableProfile: """ Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable. """ - print(f'get_peer_termination({termination}, {position})') - print(f' Mapping {termination.cable_end} {termination.cable_connector}:{position}...') connector, position = self.get_mapped_position( termination.cable_end, termination.cable_connector, position ) - print(f' Mapped to {connector}:{position}') try: ct = CableTermination.objects.get( cable=termination.cable, @@ -51,10 +56,8 @@ class BaseCableProfile: connector=connector, positions__contains=[position], ) - print(f' Found termination {ct.termination}') return ct.termination, position except CableTermination.DoesNotExist: - print(f' Failed to resolve far end termination for {connector}:{position}') return None, None diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index ef5527418..24ce1fea3 100644 --- a/netbox/dcim/filtersets.py +++ b/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') + 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') + fields = ( + 'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector', + ) @register_filterset @@ -1774,6 +1778,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, model = PowerPort fields = ( 'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end', + 'cable_connector', ) @@ -1800,6 +1805,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe model = PowerOutlet fields = ( 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', + 'cable_connector', ) @@ -2109,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_id', 'cable_end', 'cable_connector', ) def filter_virtual_chassis_member_or_master(self, queryset, name, value): @@ -2165,6 +2171,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) model = FrontPort fields = ( 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', + 'cable_connector', ) @@ -2185,6 +2192,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): model = RearPort fields = ( 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', + 'cable_connector', ) @@ -2659,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', 'description', + 'available_power', 'mark_connected', 'cable_end', 'cable_connector', 'description', ) def search(self, queryset, name, value): diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 823198908..cb1ddd583 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -532,7 +532,7 @@ class CableTermination(ChangeLoggedModel): termination.cable = None termination.cable_end = None termination.cable_connector = None - termination.cable_position = None + termination.cable_positions = None termination.save() super().delete(*args, **kwargs) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 622380e30..f3dd6f28d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -224,9 +224,9 @@ class CabledObjectModel(models.Model): raise ValidationError({ "cable_end": _("Cable end must not be set without a cable.") }) - if self.cable_position and not self.cable: + if self.cable_positions and not self.cable: raise ValidationError({ - "cable_position": _("Cable termination position must not be set without a cable.") + "cable_positions": _("Cable termination positions must not be set without a cable.") }) if self.mark_connected and self.cable: raise ValidationError({ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a76e9563a..1f07368f9 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2620,7 +2620,7 @@ class CableTerminationTest( APIViewTestCases.ListObjectsViewTestCase, ): model = CableTermination - brief_fields = ['cable', 'cable_end', 'display', 'id', 'position', 'termination_id', 'termination_type', 'url'] + brief_fields = ['cable', 'cable_end', 'display', 'id', 'positions', 'termination_id', 'termination_type', 'url'] @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 51353b9f3..96a0f14fb 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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): From 60e38ec016bf665b817788c6ad89f0ab50ed67b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Dec 2025 10:53:10 -0500 Subject: [PATCH 3/6] Fix shuffle breakout mapping --- netbox/dcim/cable_profiles.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 3e7f24f60..bad85bfaa 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -228,10 +228,10 @@ class ShuffleBreakout2x8CableProfile(BaseCableProfile): (1, 2): (2, 1), (1, 3): (5, 1), (1, 4): (6, 1), - (2, 1): (3, 2), - (2, 2): (4, 2), - (2, 3): (7, 2), - (2, 4): (8, 2), + (2, 1): (3, 1), + (2, 2): (4, 1), + (2, 3): (7, 1), + (2, 4): (8, 1), } _b_mapping = { (1, 1): (1, 1), From fa2a70b098f296e2e5e3af9dc40ae05fa79e7241 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Dec 2025 11:48:22 -0500 Subject: [PATCH 4/6] Rename migrations --- ...0054_cable_position.py => 0054_cable_connector_positions.py} | 0 ...0221_cable_position.py => 0221_cable_connector_positions.py} | 0 netbox/dcim/migrations/0222_port_mappings.py | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename netbox/circuits/migrations/{0054_cable_position.py => 0054_cable_connector_positions.py} (100%) rename netbox/dcim/migrations/{0221_cable_position.py => 0221_cable_connector_positions.py} (100%) diff --git a/netbox/circuits/migrations/0054_cable_position.py b/netbox/circuits/migrations/0054_cable_connector_positions.py similarity index 100% rename from netbox/circuits/migrations/0054_cable_position.py rename to netbox/circuits/migrations/0054_cable_connector_positions.py diff --git a/netbox/dcim/migrations/0221_cable_position.py b/netbox/dcim/migrations/0221_cable_connector_positions.py similarity index 100% rename from netbox/dcim/migrations/0221_cable_position.py rename to netbox/dcim/migrations/0221_cable_connector_positions.py diff --git a/netbox/dcim/migrations/0222_port_mappings.py b/netbox/dcim/migrations/0222_port_mappings.py index a163ae18d..42de44dbc 100644 --- a/netbox/dcim/migrations/0222_port_mappings.py +++ b/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 = [ From 5b23866dcbc90db726e294a85d244e4dda7dbb3a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 12 Dec 2025 11:48:51 -0500 Subject: [PATCH 5/6] Clean up validation logic --- netbox/dcim/models/device_components.py | 37 +++++++++++++++++-------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index f3dd6f28d..f29289647 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -220,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_positions and not self.cable: - raise ValidationError({ - "cable_positions": _("Cable termination positions 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): From ad8f3315bc080410241d96c6d7e4e56dc4694518 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Sun, 14 Dec 2025 12:29:13 -0500 Subject: [PATCH 6/6] Flesh out cable profiles & tests --- netbox/dcim/cable_profiles.py | 160 ++++- netbox/dcim/choices.py | 88 ++- netbox/dcim/models/cables.py | 27 +- netbox/dcim/tests/test_cablepaths2.py | 801 ++++++++++++++++++++------ 4 files changed, 835 insertions(+), 241 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index bad85bfaa..66cfa13a4 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -5,6 +5,12 @@ from dcim.models import CableTermination class BaseCableProfile: + """Base class for representing a cable profile.""" + + # Mappings of connectors to their available positions 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 = {} def clean(self, cable): # Enforce maximum terminations limits @@ -61,35 +67,56 @@ class BaseCableProfile: return None, None -class Straight1C1PCableProfile(BaseCableProfile): +class Single1C1PCableProfile(BaseCableProfile): a_connectors = { 1: [1], } b_connectors = a_connectors -class Straight1C2PCableProfile(BaseCableProfile): +class Single1C2PCableProfile(BaseCableProfile): a_connectors = { 1: [1, 2], } b_connectors = a_connectors -class Straight1C4PCableProfile(BaseCableProfile): +class Single1C4PCableProfile(BaseCableProfile): a_connectors = { 1: [1, 2, 3, 4], } b_connectors = a_connectors -class Straight1C8PCableProfile(BaseCableProfile): +class Single1C6PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6], + } + b_connectors = a_connectors + + +class Single1C8PCableProfile(BaseCableProfile): a_connectors = { 1: [1, 2, 3, 4, 5, 6, 7, 8], } b_connectors = a_connectors -class Straight2C1PCableProfile(BaseCableProfile): +class Single1C12PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + } + b_connectors = a_connectors + + +class Single1C16PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16], + } + b_connectors = a_connectors + + +class Trunk2C1PCableProfile(BaseCableProfile): a_connectors = { 1: [1], 2: [1], @@ -97,7 +124,7 @@ class Straight2C1PCableProfile(BaseCableProfile): b_connectors = a_connectors -class Straight2C2PCableProfile(BaseCableProfile): +class Trunk2C2PCableProfile(BaseCableProfile): a_connectors = { 1: [1, 2], 2: [1, 2], @@ -105,7 +132,104 @@ class Straight2C2PCableProfile(BaseCableProfile): b_connectors = a_connectors +class Trunk2C4PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 4], + } + b_connectors = a_connectors + + +class Trunk2C6PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6], + 2: [1, 2, 3, 4, 5, 6], + } + b_connectors = a_connectors + + +class Trunk2C8PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6, 7, 8], + 2: [1, 2, 3, 4, 5, 6, 7, 8], + } + b_connectors = a_connectors + + +class Trunk2C12PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + 2: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 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: [1, 2], + 2: [1, 2], + 3: [1, 2], + 4: [1, 2], + } + b_connectors = a_connectors + + +class Trunk4C4PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 4], + 3: [1, 2, 3, 4], + 4: [1, 2, 3, 4], + } + b_connectors = a_connectors + + +class Trunk4C6PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6], + 2: [1, 2, 3, 4, 5, 6], + 3: [1, 2, 3, 4, 5, 6], + 4: [1, 2, 3, 4, 5, 6], + } + b_connectors = a_connectors + + +class Trunk4C8PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4, 5, 6, 7, 8], + 2: [1, 2, 3, 4, 5, 6, 7, 8], + 3: [1, 2, 3, 4, 5, 6, 7, 8], + 4: [1, 2, 3, 4, 5, 6, 7, 8], + } + b_connectors = a_connectors + + +class Trunk8C4PCableProfile(BaseCableProfile): + a_connectors = { + 1: [1, 2, 3, 4], + 2: [1, 2, 3, 4], + 3: [1, 2, 3, 4], + 4: [1, 2, 3, 4], + 5: [1, 2, 3, 4], + 6: [1, 2, 3, 4], + 7: [1, 2, 3, 4], + 8: [1, 2, 3, 4], + } + b_connectors = a_connectors + + class Breakout1x4CableProfile(BaseCableProfile): + """Breakout 1:4 to 4:1""" a_connectors = { 1: [1, 2, 3, 4], } @@ -129,30 +253,6 @@ class Breakout1x4CableProfile(BaseCableProfile): return self._mapping.get((connector, position)) -class MPOTrunk4x4CableProfile(BaseCableProfile): - a_connectors = { - 1: [1, 2, 3, 4], - 2: [1, 2, 3, 4], - 3: [1, 2, 3, 4], - 4: [1, 2, 3, 4], - } - b_connectors = a_connectors - - -class MPOTrunk8x8CableProfile(BaseCableProfile): - a_connectors = { - 1: [1, 2, 3, 4], - 2: [1, 2, 3, 4], - 3: [1, 2, 3, 4], - 4: [1, 2, 3, 4], - 5: [1, 2, 3, 4], - 6: [1, 2, 3, 4], - 7: [1, 2, 3, 4], - 8: [1, 2, 3, 4], - } - b_connectors = a_connectors - - class Shuffle2x2MPO8CableProfile(BaseCableProfile): a_connectors = { 1: [1, 2, 3, 4], diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 98225d276..8080005ea 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1722,32 +1722,78 @@ class PortTypeChoices(ChoiceSet): # class CableProfileChoices(ChoiceSet): - STRAIGHT_1C1P = 'straight-1c1p' - STRAIGHT_1C2P = 'straight-1c2p' - STRAIGHT_1C4P = 'straight-1c4p' - STRAIGHT_1C8P = 'straight-1c8p' - STRAIGHT_2C1P = 'straight-2c1p' - STRAIGHT_2C2P = 'straight-2c2p' + # 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_2C6P = 'trunk-2c6p' + TRUNK_2C8P = 'trunk-2c8p' + TRUNK_2C12P = 'trunk-2c12p' + TRUNK_4C1P = 'trunk-4c1p' + TRUNK_4C2P = 'trunk-4c2p' + TRUNK_4C4P = 'trunk-4c4p' + TRUNK_4C6P = 'trunk-4c6p' + TRUNK_4C8P = 'trunk-4c8p' + TRUNK_8C4P = 'trunk-8c4p' + # Breakouts BREAKOUT_1X4 = 'breakout-1x4' - MPO_TRUNK_4X4 = 'mpo-trunk-4x4' - MPO_TRUNK_8X8 = 'mpo-trunk-8x8' + SHUFFLE_BREAKOUT_2X8 = 'shuffle-breakout-2x8' + # Shuffles SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8' SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8' - SHUFFLE_BREAKOUT_2X8 = 'shuffle-breakout-2x8' CHOICES = ( - (STRAIGHT_1C1P, _('Straight (1C1P)')), - (STRAIGHT_1C2P, _('Straight (1C2P)')), - (STRAIGHT_1C4P, _('Straight (1C4P)')), - (STRAIGHT_1C8P, _('Straight (1C8P)')), - (STRAIGHT_2C1P, _('Straight (2C1P)')), - (STRAIGHT_2C2P, _('Straight (2C2P)')), - (BREAKOUT_1X4, _('Breakout (1:4)')), - (MPO_TRUNK_4X4, _('MPO Trunk (4x4)')), - (MPO_TRUNK_8X8, _('MPO Trunk (8x8)')), - (SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')), - (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')), - (SHUFFLE_BREAKOUT_2X8, _('Shuffle breakout (2x8)')), + ( + _('Single'), + ( + (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)')), + ), + ), + ( + _('Trunk'), + ( + (TRUNK_2C1P, _('Trunk (2C1P)')), + (TRUNK_2C2P, _('Trunk (2C2P)')), + (TRUNK_2C4P, _('Trunk (2C4P)')), + (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_4C6P, _('Trunk (4C6P)')), + (TRUNK_4C8P, _('Trunk (4C8P)')), + (TRUNK_8C4P, _('Trunk (8C4P)')), + ), + ), + ( + _('Breakouts'), + ( + (BREAKOUT_1X4, _('Breakout (1:4)')), + ), + ), + ( + _('Shuffles'), + ( + (SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')), + (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')), + (SHUFFLE_BREAKOUT_2X8, _('Shuffle breakout (2x8)')), + ), + ), ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index cb1ddd583..b15185ede 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -137,15 +137,26 @@ class Cable(PrimaryModel): def profile_class(self): from dcim import cable_profiles return { - CableProfileChoices.STRAIGHT_1C1P: cable_profiles.Straight1C1PCableProfile, - CableProfileChoices.STRAIGHT_1C2P: cable_profiles.Straight1C2PCableProfile, - CableProfileChoices.STRAIGHT_1C4P: cable_profiles.Straight1C4PCableProfile, - CableProfileChoices.STRAIGHT_1C8P: cable_profiles.Straight1C8PCableProfile, - CableProfileChoices.STRAIGHT_2C1P: cable_profiles.Straight2C1PCableProfile, - CableProfileChoices.STRAIGHT_2C2P: cable_profiles.Straight2C2PCableProfile, + 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_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_4C6P: cable_profiles.Trunk4C6PCableProfile, + CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile, + CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile, CableProfileChoices.BREAKOUT_1X4: cable_profiles.Breakout1x4CableProfile, - CableProfileChoices.MPO_TRUNK_4X4: cable_profiles.MPOTrunk4x4CableProfile, - CableProfileChoices.MPO_TRUNK_8X8: cable_profiles.MPOTrunk8x8CableProfile, CableProfileChoices.SHUFFLE_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile, CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile, CableProfileChoices.SHUFFLE_BREAKOUT_2X8: cable_profiles.ShuffleBreakout2x8CableProfile, diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 1aae6ddaf..ac9dd6d9d 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -16,11 +16,11 @@ class CablePathTests(CablePathTestCase): 2XX: Topology tests replicated from the legacy test case and adapted to use profiles """ - def test_101_cable_profile_straight_1c1p(self): + def test_101_cable_profile_single_1c1p(self): """ [IF1] --C1-- [IF2] - Cable profile: Straight 1C1P + Cable profile: Single connector, single position """ interfaces = [ Interface.objects.create(device=self.device, name='Interface 1'), @@ -29,7 +29,7 @@ class CablePathTests(CablePathTestCase): # Create cable 1 cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_1C1P, + profile=CableProfileChoices.SINGLE_1C1P, a_terminations=[interfaces[0]], b_terminations=[interfaces[1]], ) @@ -65,12 +65,148 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_102_cable_profile_straight_2c1p(self): + def test_102_cable_profile_single_1c2p(self): + """ + [IF1] --C1-- [FP1][RP1] --C3-- [RP2][FP3] --C4-- [IF3] + [IF2] --C2-- [FP2] [FP4] --C5-- [IF4] + + Cable profile: Single connector, multiple positions + """ + 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'), + Interface.objects.create(device=self.device, name='Interface 4'), + ] + rear_ports = [ + RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2), + RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2), + ] + front_ports = [ + FrontPort.objects.create(device=self.device, name='Front Port 1'), + FrontPort.objects.create(device=self.device, name='Front Port 2'), + FrontPort.objects.create(device=self.device, name='Front Port 3'), + FrontPort.objects.create(device=self.device, name='Front Port 4'), + ] + 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[0], + rear_port_position=2, + ), + PortMapping( + device=self.device, + front_port=front_ports[2], + front_port_position=1, + rear_port=rear_ports[1], + rear_port_position=1, + ), + PortMapping( + device=self.device, + front_port=front_ports[3], + front_port_position=1, + rear_port=rear_ports[1], + rear_port_position=2, + ), + ]) + + # Create cables + cable1 = Cable( + a_terminations=[interfaces[0]], + b_terminations=[front_ports[0]], + ) + cable1.clean() + cable1.save() + cable2 = Cable( + a_terminations=[interfaces[1]], + b_terminations=[front_ports[1]], + ) + cable2.clean() + cable2.save() + cable3 = Cable( + profile=CableProfileChoices.SINGLE_1C2P, + a_terminations=[rear_ports[0]], + b_terminations=[rear_ports[1]], + ) + cable3.clean() + cable3.save() + cable4 = Cable( + a_terminations=[interfaces[2]], + b_terminations=[front_ports[2]], + ) + cable4.clean() + cable4.save() + cable5 = Cable( + a_terminations=[interfaces[3]], + b_terminations=[front_ports[3]], + ) + cable5.clean() + cable5.save() + + path1 = self.assertPathExists( + ( + interfaces[0], cable1, front_ports[0], rear_ports[0], cable3, rear_ports[1], front_ports[2], cable4, + interfaces[2], + ), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ( + interfaces[1], cable2, front_ports[1], rear_ports[0], cable3, rear_ports[1], front_ports[3], cable5, + interfaces[3], + ), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + ( + interfaces[2], cable4, front_ports[2], rear_ports[1], cable3, rear_ports[0], front_ports[0], cable1, + interfaces[0], + ), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + ( + interfaces[3], cable5, front_ports[3], rear_ports[1], cable3, rear_ports[0], front_ports[1], cable2, + interfaces[1], + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + for iface in interfaces: + iface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + self.assertPathIsSet(interfaces[3], path4) + for rear_port in rear_ports: + rear_port.refresh_from_db() + self.assertEqual(rear_ports[0].cable_connector, 1) + self.assertEqual(rear_ports[0].cable_positions, [1, 2]) + self.assertEqual(rear_ports[1].cable_connector, 1) + self.assertEqual(rear_ports[1].cable_positions, [1, 2]) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + def test_103_cable_profile_trunk_2c1p(self): """ [IF1] --C1-- [IF3] [IF2] [IF4] - Cable profile: Straight 2C1P + Cable profile: Multiple connectors, single position """ interfaces = [ Interface.objects.create(device=self.device, name='Interface 1'), @@ -81,7 +217,7 @@ class CablePathTests(CablePathTestCase): # Create cable 1 cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_2C1P, + profile=CableProfileChoices.TRUNK_2C1P, a_terminations=[interfaces[0], interfaces[1]], b_terminations=[interfaces[2], interfaces[3]], ) @@ -134,17 +270,378 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - @skip("Under development") - def test_103_cable_profile_shuffle_2x2_mpo8(self): + def test_104_cable_profile_trunk_2c2p(self): """ - [IF1:1] --C1-- [IF3:1] - [IF1:2] [IF3:2] - [IF1:3] [IF3:3] - [IF1:4] [IF3:4] - [IF2:1] [IF4:1] - [IF2:2] [IF4:2] - [IF2:3] [IF4:3] - [IF2:4] [IF4:4] + [IF1] --C1-- [FP1][RP1] --C9-- [RP3][FP5] --C5-- [IF5] + [IF2] --C2-- [FP2] [FP6] --C6-- [IF6] + [IF3] --C3-- [FP3][RP2] [RP4][FP7] --C7-- [IF7] + [IF4] --C4-- [FP4] [FP8] --C8-- [IF8] + + Cable profile: Multiple connectors, multiple positions + """ + 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'), + Interface.objects.create(device=self.device, name='Interface 4'), + Interface.objects.create(device=self.device, name='Interface 5'), + Interface.objects.create(device=self.device, name='Interface 6'), + Interface.objects.create(device=self.device, name='Interface 7'), + Interface.objects.create(device=self.device, name='Interface 8'), + ] + rear_ports = [ + RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2), + RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2), + RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2), + RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2), + ] + front_ports = [ + FrontPort.objects.create(device=self.device, name='Front Port 1'), + FrontPort.objects.create(device=self.device, name='Front Port 2'), + FrontPort.objects.create(device=self.device, name='Front Port 3'), + FrontPort.objects.create(device=self.device, name='Front Port 4'), + FrontPort.objects.create(device=self.device, name='Front Port 5'), + FrontPort.objects.create(device=self.device, name='Front Port 6'), + FrontPort.objects.create(device=self.device, name='Front Port 7'), + FrontPort.objects.create(device=self.device, name='Front Port 8'), + ] + 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[0], + rear_port_position=2, + ), + PortMapping( + device=self.device, + front_port=front_ports[2], + front_port_position=1, + rear_port=rear_ports[1], + rear_port_position=1, + ), + PortMapping( + device=self.device, + front_port=front_ports[3], + front_port_position=1, + rear_port=rear_ports[1], + rear_port_position=2, + ), + PortMapping( + device=self.device, + front_port=front_ports[4], + front_port_position=1, + rear_port=rear_ports[2], + rear_port_position=1, + ), + PortMapping( + device=self.device, + front_port=front_ports[5], + front_port_position=1, + rear_port=rear_ports[2], + rear_port_position=2, + ), + PortMapping( + device=self.device, + front_port=front_ports[6], + front_port_position=1, + rear_port=rear_ports[3], + rear_port_position=1, + ), + PortMapping( + device=self.device, + front_port=front_ports[7], + front_port_position=1, + rear_port=rear_ports[3], + rear_port_position=2, + ), + ]) + + # Create cables + cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[front_ports[0]]) + cable1.clean() + cable1.save() + cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[front_ports[1]]) + cable2.clean() + cable2.save() + cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[front_ports[2]]) + cable3.clean() + cable3.save() + cable4 = Cable(a_terminations=[interfaces[3]], b_terminations=[front_ports[3]]) + cable4.clean() + cable4.save() + cable5 = Cable(a_terminations=[interfaces[4]], b_terminations=[front_ports[4]]) + cable5.clean() + cable5.save() + cable6 = Cable(a_terminations=[interfaces[5]], b_terminations=[front_ports[5]]) + cable6.clean() + cable6.save() + cable7 = Cable(a_terminations=[interfaces[6]], b_terminations=[front_ports[6]]) + cable7.clean() + cable7.save() + cable8 = Cable(a_terminations=[interfaces[7]], b_terminations=[front_ports[7]]) + cable8.clean() + cable8.save() + cable9 = Cable( + profile=CableProfileChoices.TRUNK_2C2P, + a_terminations=[rear_ports[0], rear_ports[1]], + b_terminations=[rear_ports[2], rear_ports[3]] + ) + cable9.clean() + cable9.save() + + path1 = self.assertPathExists( + ( + interfaces[0], cable1, front_ports[0], rear_ports[0], cable9, rear_ports[2], front_ports[4], cable5, + interfaces[4], + ), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ( + interfaces[1], cable2, front_ports[1], rear_ports[0], cable9, rear_ports[2], front_ports[5], cable6, + interfaces[5], + ), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + ( + interfaces[2], cable3, front_ports[2], rear_ports[1], cable9, rear_ports[3], front_ports[6], cable7, + interfaces[6], + ), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + ( + interfaces[3], cable4, front_ports[3], rear_ports[1], cable9, rear_ports[3], front_ports[7], cable8, + interfaces[7], + ), + is_complete=True, + is_active=True + ) + path5 = self.assertPathExists( + ( + interfaces[4], cable5, front_ports[4], rear_ports[2], cable9, rear_ports[0], front_ports[0], cable1, + interfaces[0], + ), + is_complete=True, + is_active=True + ) + path6 = self.assertPathExists( + ( + interfaces[5], cable6, front_ports[5], rear_ports[2], cable9, rear_ports[0], front_ports[1], cable2, + interfaces[1], + ), + is_complete=True, + is_active=True + ) + path7 = self.assertPathExists( + ( + interfaces[6], cable7, front_ports[6], rear_ports[3], cable9, rear_ports[1], front_ports[2], cable3, + interfaces[2], + ), + is_complete=True, + is_active=True + ) + path8 = self.assertPathExists( + ( + interfaces[7], cable8, front_ports[7], rear_ports[3], cable9, rear_ports[1], front_ports[3], cable4, + interfaces[3], + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 8) + for iface in interfaces: + iface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + self.assertPathIsSet(interfaces[3], path4) + self.assertPathIsSet(interfaces[4], path5) + self.assertPathIsSet(interfaces[5], path6) + self.assertPathIsSet(interfaces[6], path7) + self.assertPathIsSet(interfaces[7], path8) + for rear_port in rear_ports: + rear_port.refresh_from_db() + self.assertEqual(rear_ports[0].cable_connector, 1) + self.assertEqual(rear_ports[0].cable_positions, [1, 2]) + self.assertEqual(rear_ports[1].cable_connector, 2) + self.assertEqual(rear_ports[1].cable_positions, [1, 2]) + self.assertEqual(rear_ports[2].cable_connector, 1) + self.assertEqual(rear_ports[2].cable_positions, [1, 2]) + self.assertEqual(rear_ports[3].cable_connector, 2) + self.assertEqual(rear_ports[3].cable_positions, [1, 2]) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + def test_105_cable_profile_breakout(self): + """ + [IF1] --C1-- [FP1][RP1] --C2-- [IF5] + [IF2] --C3-- [FP2] [IF6] + [IF3] --C4-- [FP3] [IF7] + [IF4] --C5-- [FP4] [IF8] + + Cable profile: 1:4 breakout + """ + 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'), + Interface.objects.create(device=self.device, name='Interface 4'), + Interface.objects.create(device=self.device, name='Interface 5'), + Interface.objects.create(device=self.device, name='Interface 6'), + Interface.objects.create(device=self.device, name='Interface 7'), + Interface.objects.create(device=self.device, name='Interface 8'), + ] + rear_ports = [ + RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4), + ] + front_ports = [ + FrontPort.objects.create(device=self.device, name='Front Port 1'), + FrontPort.objects.create(device=self.device, name='Front Port 2'), + FrontPort.objects.create(device=self.device, name='Front Port 3'), + FrontPort.objects.create(device=self.device, name='Front Port 4'), + ] + 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[0], + rear_port_position=2, + ), + PortMapping( + device=self.device, + front_port=front_ports[2], + front_port_position=1, + rear_port=rear_ports[0], + rear_port_position=3, + ), + PortMapping( + device=self.device, + front_port=front_ports[3], + front_port_position=1, + rear_port=rear_ports[0], + rear_port_position=4, + ), + ]) + + # Create cables + cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[front_ports[0]]) + cable1.clean() + cable1.save() + cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[front_ports[1]]) + cable2.clean() + cable2.save() + cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[front_ports[2]]) + cable3.clean() + cable3.save() + cable4 = Cable(a_terminations=[interfaces[3]], b_terminations=[front_ports[3]]) + cable4.clean() + cable4.save() + cable5 = Cable( + profile=CableProfileChoices.BREAKOUT_1X4, + a_terminations=[rear_ports[0]], + b_terminations=interfaces[4:8], + ) + cable5.clean() + cable5.save() + + path1 = self.assertPathExists( + (interfaces[0], cable1, front_ports[0], rear_ports[0], cable5, interfaces[4]), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + (interfaces[1], cable2, front_ports[1], rear_ports[0], cable5, interfaces[5]), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + (interfaces[2], cable3, front_ports[2], rear_ports[0], cable5, interfaces[6]), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + (interfaces[3], cable4, front_ports[3], rear_ports[0], cable5, interfaces[7]), + is_complete=True, + is_active=True + ) + path5 = self.assertPathExists( + (interfaces[4], cable5, rear_ports[0], front_ports[0], cable1, interfaces[0]), + is_complete=True, + is_active=True + ) + path6 = self.assertPathExists( + (interfaces[5], cable5, rear_ports[0], front_ports[1], cable2, interfaces[1]), + is_complete=True, + is_active=True + ) + path7 = self.assertPathExists( + (interfaces[6], cable5, rear_ports[0], front_ports[2], cable3, interfaces[2]), + is_complete=True, + is_active=True + ) + path8 = self.assertPathExists( + (interfaces[7], cable5, rear_ports[0], front_ports[3], cable4, interfaces[3]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 8) + for interface in interfaces: + interface.refresh_from_db() + self.assertPathIsSet(interfaces[0], path1) + self.assertPathIsSet(interfaces[1], path2) + self.assertPathIsSet(interfaces[2], path3) + self.assertPathIsSet(interfaces[3], path4) + self.assertPathIsSet(interfaces[4], path5) + self.assertPathIsSet(interfaces[5], path6) + self.assertPathIsSet(interfaces[6], path7) + self.assertPathIsSet(interfaces[7], path8) + self.assertEqual(interfaces[4].cable_connector, 1) + self.assertEqual(interfaces[4].cable_positions, [1]) + self.assertEqual(interfaces[5].cable_connector, 2) + self.assertEqual(interfaces[5].cable_positions, [1]) + self.assertEqual(interfaces[6].cable_connector, 3) + self.assertEqual(interfaces[6].cable_positions, [1]) + self.assertEqual(interfaces[7].cable_connector, 4) + self.assertEqual(interfaces[7].cable_positions, [1]) + rear_ports[0].refresh_from_db() + self.assertEqual(rear_ports[0].cable_connector, 1) + self.assertEqual(rear_ports[0].cable_positions, [1, 2, 3, 4]) + + # Test SVG generation + CableTraceSVG(interfaces[0]).render() + + def test_106_cable_profile_shuffle_2x2_mpo8(self): + """ + [IF1] --C1-- [FP1][RP1] --C17-- [RP3][FP9] --C9-- [IF9] + [IF2] --C2-- [FP2] [FP10] --C10-- [IF10] + [IF3] --C3-- [FP3] [FP11] --C11-- [IF11] + [IF4] --C4-- [FP4] [FP12] --C12-- [IF12] + [IF5] --C5-- [FP5][RP2] [RP4][FP13] --C13-- [IF9] + [IF6] --C6-- [FP6] [FP14] --C14-- [IF10] + [IF7] --C7-- [FP7] [FP15] --C15-- [IF11] + [IF8] --C8-- [FP8] [FP16] --C16-- [IF12] Cable profile: Shuffle (2x2 MPO8) """ @@ -168,198 +665,138 @@ class CablePathTests(CablePathTestCase): Interface.objects.create(device=self.device, name='Interface 4:3'), Interface.objects.create(device=self.device, name='Interface 4:4'), ] + rear_ports = [ + RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4), + RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4), + RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4), + RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4), + ] + front_ports = [ + FrontPort.objects.create(device=self.device, name='Front Port 1'), + FrontPort.objects.create(device=self.device, name='Front Port 2'), + FrontPort.objects.create(device=self.device, name='Front Port 3'), + FrontPort.objects.create(device=self.device, name='Front Port 4'), + FrontPort.objects.create(device=self.device, name='Front Port 5'), + FrontPort.objects.create(device=self.device, name='Front Port 6'), + FrontPort.objects.create(device=self.device, name='Front Port 7'), + FrontPort.objects.create(device=self.device, name='Front Port 8'), + FrontPort.objects.create(device=self.device, name='Front Port 9'), + FrontPort.objects.create(device=self.device, name='Front Port 10'), + FrontPort.objects.create(device=self.device, name='Front Port 11'), + FrontPort.objects.create(device=self.device, name='Front Port 12'), + FrontPort.objects.create(device=self.device, name='Front Port 13'), + FrontPort.objects.create(device=self.device, name='Front Port 14'), + FrontPort.objects.create(device=self.device, name='Front Port 15'), + FrontPort.objects.create(device=self.device, name='Front Port 16'), + ] + port_mappings = [] + for i, front_port in enumerate(front_ports): + port_mappings.append( + PortMapping( + device=self.device, + front_port=front_ports[i], + front_port_position=1, + rear_port=rear_ports[int(i / 4)], + rear_port_position=(i % 4) + 1, + ), + ) + PortMapping.objects.bulk_create(port_mappings) - # Create cable 1 - cable1 = Cable( + # Create cables + cables = [] + for interface, front_port in zip(interfaces, front_ports): + cable = Cable(a_terminations=[interface], b_terminations=[front_port]) + cable.clean() + cable.save() + cables.append(cable) + shuffle_cable = Cable( profile=CableProfileChoices.SHUFFLE_2X2_MPO8, - a_terminations=interfaces[0:8], - b_terminations=interfaces[8:16], + a_terminations=rear_ports[0:2], + b_terminations=rear_ports[2:4], ) - cable1.clean() - cable1.save() + shuffle_cable.clean() + shuffle_cable.save() paths = [ # A-to-B paths self.assertPathExists( - (interfaces[0], cable1, interfaces[8]), is_complete=True, is_active=True + ( + interfaces[0], cables[0], front_ports[0], rear_ports[0], shuffle_cable, rear_ports[2], + front_ports[8], cables[8], interfaces[8], + ), + is_complete=True, + is_active=True ), self.assertPathExists( - (interfaces[1], cable1, interfaces[9]), is_complete=True, is_active=True + ( + interfaces[1], cables[1], front_ports[1], rear_ports[0], shuffle_cable, rear_ports[2], + front_ports[9], cables[9], interfaces[9], + ), + is_complete=True, + is_active=True ), self.assertPathExists( - (interfaces[2], cable1, interfaces[12]), is_complete=True, is_active=True + ( + interfaces[2], cables[2], front_ports[2], rear_ports[0], shuffle_cable, rear_ports[3], + front_ports[12], cables[12], interfaces[12], + ), + is_complete=True, + is_active=True ), self.assertPathExists( - (interfaces[3], cable1, interfaces[13]), is_complete=True, is_active=True + ( + interfaces[3], cables[3], front_ports[3], rear_ports[0], shuffle_cable, rear_ports[3], + front_ports[13], cables[13], interfaces[13], + ), + is_complete=True, + is_active=True ), self.assertPathExists( - (interfaces[4], cable1, interfaces[10]), is_complete=True, is_active=True + ( + interfaces[4], cables[4], front_ports[4], rear_ports[1], shuffle_cable, rear_ports[2], + front_ports[10], cables[10], interfaces[10], + ), + is_complete=True, + is_active=True ), self.assertPathExists( - (interfaces[5], cable1, interfaces[11]), is_complete=True, is_active=True + ( + interfaces[5], cables[5], front_ports[5], rear_ports[1], shuffle_cable, rear_ports[2], + front_ports[11], cables[11], interfaces[11], + ), + is_complete=True, + is_active=True ), self.assertPathExists( - (interfaces[6], cable1, interfaces[14]), is_complete=True, is_active=True + ( + interfaces[6], cables[6], front_ports[6], rear_ports[1], shuffle_cable, rear_ports[3], + front_ports[14], cables[14], interfaces[14], + ), + is_complete=True, + is_active=True ), self.assertPathExists( - (interfaces[7], cable1, interfaces[15]), is_complete=True, is_active=True - ), - # B-to-A paths - self.assertPathExists( - (interfaces[8], cable1, interfaces[0]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[9], cable1, interfaces[1]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[10], cable1, interfaces[4]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[11], cable1, interfaces[5]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[12], cable1, interfaces[2]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[13], cable1, interfaces[3]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[14], cable1, interfaces[6]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[15], cable1, interfaces[7]), is_complete=True, is_active=True + ( + interfaces[7], cables[7], front_ports[7], rear_ports[1], shuffle_cable, rear_ports[3], + front_ports[15], cables[15], interfaces[15], + ), + is_complete=True, + is_active=True ), ] - self.assertEqual(CablePath.objects.count(), len(paths)) + self.assertEqual(CablePath.objects.count(), len(paths) * 2) for i, (interface, path) in enumerate(zip(interfaces, paths)): interface.refresh_from_db() self.assertPathIsSet(interface, path) - self.assertEqual(interface.cable_end, 'A' if i < 8 else 'B') - self.assertEqual(interface.cable_positions, [(i % 8) + 1]) + for i, rear_port in enumerate(rear_ports): + rear_port.refresh_from_db() + self.assertEqual(rear_port.cable_connector, (i % 2) + 1) + self.assertEqual(rear_port.cable_positions, [1, 2, 3, 4]) # Test SVG generation CableTraceSVG(interfaces[0]).render() - # Delete cable 1 - cable1.delete() - - # Check that all CablePaths have been deleted - self.assertEqual(CablePath.objects.count(), 0) - - @skip("Under development") - def test_104_cable_profile_4x4_mpo8(self): - """ - [IF1:1] --C1-- [IF3:1] - [IF1:2] [IF3:2] - [IF1:3] [IF3:3] - [IF1:4] [IF3:4] - [IF2:1] [IF4:1] - [IF2:2] [IF4:2] - [IF2:3] [IF4:3] - [IF2:4] [IF4:4] - - Cable profile: Shuffle (4x4 MPO8) - """ - interfaces = [ - # A side - Interface.objects.create(device=self.device, name='Interface 1:1'), - Interface.objects.create(device=self.device, name='Interface 1:2'), - Interface.objects.create(device=self.device, name='Interface 2:1'), - Interface.objects.create(device=self.device, name='Interface 2:2'), - Interface.objects.create(device=self.device, name='Interface 3:1'), - Interface.objects.create(device=self.device, name='Interface 3:2'), - Interface.objects.create(device=self.device, name='Interface 4:1'), - Interface.objects.create(device=self.device, name='Interface 4:2'), - # B side - Interface.objects.create(device=self.device, name='Interface 5:1'), - Interface.objects.create(device=self.device, name='Interface 5:2'), - Interface.objects.create(device=self.device, name='Interface 6:1'), - Interface.objects.create(device=self.device, name='Interface 6:2'), - Interface.objects.create(device=self.device, name='Interface 7:1'), - Interface.objects.create(device=self.device, name='Interface 7:2'), - Interface.objects.create(device=self.device, name='Interface 8:1'), - Interface.objects.create(device=self.device, name='Interface 8:2'), - ] - - # Create cable 1 - cable1 = Cable( - profile=CableProfileChoices.SHUFFLE_4X4_MPO8, - a_terminations=interfaces[0:8], - b_terminations=interfaces[8:16], - ) - cable1.clean() - cable1.save() - - paths = [ - # A-to-B paths - self.assertPathExists( - (interfaces[0], cable1, interfaces[8]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[1], cable1, interfaces[10]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[2], cable1, interfaces[12]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[3], cable1, interfaces[14]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[4], cable1, interfaces[9]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[5], cable1, interfaces[11]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[6], cable1, interfaces[13]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[7], cable1, interfaces[15]), is_complete=True, is_active=True - ), - # B-to-A paths - self.assertPathExists( - (interfaces[8], cable1, interfaces[0]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[9], cable1, interfaces[4]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[10], cable1, interfaces[1]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[11], cable1, interfaces[5]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[12], cable1, interfaces[2]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[13], cable1, interfaces[6]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[14], cable1, interfaces[3]), is_complete=True, is_active=True - ), - self.assertPathExists( - (interfaces[15], cable1, interfaces[7]), is_complete=True, is_active=True - ), - ] - self.assertEqual(CablePath.objects.count(), len(paths)) - - for i, (interface, path) in enumerate(zip(interfaces, paths)): - interface.refresh_from_db() - self.assertPathIsSet(interface, path) - self.assertEqual(interface.cable_end, 'A' if i < 8 else 'B') - self.assertEqual(interface.cable_positions, [(i % 8) + 1]) - - # Test SVG generation - CableTraceSVG(interfaces[0]).render() - - # Delete cable 1 - cable1.delete() - - # Check that all CablePaths have been deleted - self.assertEqual(CablePath.objects.count(), 0) - def test_202_single_path_via_pass_through_with_breakouts(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [IF3] @@ -551,7 +988,7 @@ class CablePathTests(CablePathTestCase): cable2.clean() cable2.save() cable3 = Cable( - profile=CableProfileChoices.STRAIGHT_1C8P, + profile=CableProfileChoices.SINGLE_1C8P, a_terminations=[rearport1], b_terminations=[rearport2] ) @@ -783,7 +1220,7 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_2C1P, + profile=CableProfileChoices.SINGLE_2C1P, a_terminations=[interfaces[0]], b_terminations=[front_ports[0], front_ports[1]] ) @@ -796,7 +1233,7 @@ class CablePathTests(CablePathTestCase): cable2.clean() cable2.save() cable3 = Cable( - profile=CableProfileChoices.STRAIGHT_2C1P, + profile=CableProfileChoices.SINGLE_2C1P, a_terminations=[interfaces[1]], b_terminations=[front_ports[2], front_ports[3]] ) @@ -864,14 +1301,14 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.STRAIGHT_2C2P, + profile=CableProfileChoices.TRUNK_2C2P, a_terminations=[interfaces[0], interfaces[1]], b_terminations=[frontport1, frontport2] ) cable1.clean() cable1.save() cable2 = Cable( - profile=CableProfileChoices.STRAIGHT_2C2P, + profile=CableProfileChoices.TRUNK_2C2P, a_terminations=[rearport1, rearport2], b_terminations=[interfaces[2], interfaces[3]] )