Skip to content

Commit 7cc7c7a

Browse files
Closes #20788: Cable profiles and and position mapping (#20802)
1 parent cee2a5e commit 7cc7c7a

File tree

30 files changed

+1418
-144
lines changed

30 files changed

+1418
-144
lines changed

docs/models/dcim/cable.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ The cable's operational status. Choices include:
2121
* Planned
2222
* Decommissioning
2323

24+
### Profile
25+
26+
!!! note "This field was introduced in NetBox v4.5."
27+
28+
The profile to which the cable conforms. The profile determines the mapping of termination between the two ends and enables logical tracing across complex connections, such as breakout cables. Supported profiles are listed below.
29+
30+
* Straight (single position)
31+
* Straight (multi-position)
32+
* Shuffle (2x2 MPO8)
33+
* Shuffle (4x4 MPO8)
34+
35+
A single-position cable is allowed only one termination point at each end. There is no limit to the number of terminations a multi-position cable may have. Each end of a cable must have the same number of terminations, unless connected to a pass-through port or to a circuit termination.
36+
37+
The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved.
38+
2439
### Type
2540

2641
The cable's physical medium or classification.

netbox/circuits/filtersets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ class Meta:
346346
model = CircuitTermination
347347
fields = (
348348
'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description',
349-
'mark_connected', 'pp_info', 'cable_end',
349+
'mark_connected', 'pp_info', 'cable_end', 'cable_position',
350350
)
351351

352352
def search(self, queryset, name, value):
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import django.core.validators
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
dependencies = [
7+
('circuits', '0053_owner'),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name='circuittermination',
13+
name='cable_position',
14+
field=models.PositiveIntegerField(
15+
blank=True,
16+
null=True,
17+
validators=[
18+
django.core.validators.MinValueValidator(1),
19+
django.core.validators.MaxValueValidator(1024),
20+
],
21+
),
22+
),
23+
]

netbox/dcim/api/serializers_/cables.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,16 @@ class CableSerializer(PrimaryModelSerializer):
2525
a_terminations = GenericObjectSerializer(many=True, required=False)
2626
b_terminations = GenericObjectSerializer(many=True, required=False)
2727
status = ChoiceField(choices=LinkStatusChoices, required=False)
28+
profile = ChoiceField(choices=CableProfileChoices, required=False)
2829
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
2930
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
3031

3132
class Meta:
3233
model = Cable
3334
fields = [
34-
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant',
35-
'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags', 'custom_fields',
36-
'created', 'last_updated',
35+
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
36+
'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
37+
'custom_fields', 'created', 'last_updated',
3738
]
3839
brief_fields = ('id', 'url', 'display', 'label', 'description')
3940

@@ -60,10 +61,12 @@ class Meta:
6061
model = CableTermination
6162
fields = [
6263
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
63-
'termination', 'created', 'last_updated',
64+
'termination', 'position', 'created', 'last_updated',
6465
]
6566
read_only_fields = fields
66-
brief_fields = ('id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id')
67+
brief_fields = (
68+
'id', 'url', 'display', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id',
69+
)
6770

6871

6972
class CablePathSerializer(serializers.ModelSerializer):

netbox/dcim/cable_profiles.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from django.core.exceptions import ValidationError
2+
from django.utils.translation import gettext_lazy as _
3+
4+
from dcim.models import CableTermination
5+
6+
7+
class BaseCableProfile:
8+
# Maximum number of terminations allowed per side
9+
a_max_connections = None
10+
b_max_connections = None
11+
12+
def clean(self, cable):
13+
# Enforce maximum connection limits
14+
if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections:
15+
raise ValidationError({
16+
'a_terminations': _(
17+
'Maximum A side connections for profile {profile}: {max}'
18+
).format(
19+
profile=cable.get_profile_display(),
20+
max=self.a_max_connections,
21+
)
22+
})
23+
if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections:
24+
raise ValidationError({
25+
'b_terminations': _(
26+
'Maximum B side connections for profile {profile}: {max}'
27+
).format(
28+
profile=cable.get_profile_display(),
29+
max=self.b_max_connections,
30+
)
31+
})
32+
33+
def get_mapped_position(self, side, position):
34+
"""
35+
Return the mapped position for a given cable end and position.
36+
37+
By default, assume all positions are symmetrical.
38+
"""
39+
return position
40+
41+
def get_peer_terminations(self, terminations, position_stack):
42+
local_end = terminations[0].cable_end
43+
qs = CableTermination.objects.filter(
44+
cable=terminations[0].cable,
45+
cable_end=terminations[0].opposite_cable_end
46+
)
47+
48+
# TODO: Optimize this to use a single query under any condition
49+
if position_stack:
50+
# Attempt to find a peer termination at the same position currently in the stack. Pop the stack only if
51+
# we find one. Otherwise, return any peer terminations with a null position.
52+
position = self.get_mapped_position(local_end, position_stack[-1][0])
53+
if peers := qs.filter(position=position):
54+
position_stack.pop()
55+
return peers
56+
57+
return qs.filter(position=None)
58+
59+
60+
class StraightSingleCableProfile(BaseCableProfile):
61+
a_max_connections = 1
62+
b_max_connections = 1
63+
64+
65+
class StraightMultiCableProfile(BaseCableProfile):
66+
a_max_connections = None
67+
b_max_connections = None
68+
69+
70+
class Shuffle2x2MPO8CableProfile(BaseCableProfile):
71+
a_max_connections = 8
72+
b_max_connections = 8
73+
_mapping = {
74+
1: 1,
75+
2: 2,
76+
3: 5,
77+
4: 6,
78+
5: 3,
79+
6: 4,
80+
7: 7,
81+
8: 8,
82+
}
83+
84+
def get_mapped_position(self, side, position):
85+
return self._mapping.get(position)
86+
87+
88+
class Shuffle4x4MPO8CableProfile(BaseCableProfile):
89+
a_max_connections = 8
90+
b_max_connections = 8
91+
# A side to B side position mapping
92+
_a_mapping = {
93+
1: 1,
94+
2: 3,
95+
3: 5,
96+
4: 7,
97+
5: 2,
98+
6: 4,
99+
7: 6,
100+
8: 8,
101+
}
102+
# B side to A side position mapping (reverse of _a_mapping)
103+
_b_mapping = {v: k for k, v in _a_mapping.items()}
104+
105+
def get_mapped_position(self, side, position):
106+
if side.lower() == 'b':
107+
return self._b_mapping.get(position)
108+
return self._a_mapping.get(position)

netbox/dcim/choices.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1717,6 +1717,19 @@ class PortTypeChoices(ChoiceSet):
17171717
# Cables/links
17181718
#
17191719

1720+
class CableProfileChoices(ChoiceSet):
1721+
STRAIGHT_SINGLE = 'straight-single'
1722+
STRAIGHT_MULTI = 'straight-multi'
1723+
SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8'
1724+
SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8'
1725+
1726+
CHOICES = (
1727+
(STRAIGHT_SINGLE, _('Straight (single position)')),
1728+
(STRAIGHT_MULTI, _('Straight (multi-position)')),
1729+
(SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')),
1730+
(SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')),
1731+
)
1732+
17201733

17211734
class CableTypeChoices(ChoiceSet):
17221735
# Copper - Twisted Pair (UTP/STP)

netbox/dcim/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020
RACK_STARTING_UNIT_DEFAULT = 1
2121

2222

23+
#
24+
# Cables
25+
#
26+
27+
CABLE_POSITION_MIN = 1
28+
CABLE_POSITION_MAX = 1024
29+
30+
2331
#
2432
# RearPorts
2533
#

netbox/dcim/filtersets.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1699,7 +1699,7 @@ class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
16991699

17001700
class Meta:
17011701
model = ConsolePort
1702-
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
1702+
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
17031703

17041704

17051705
class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
@@ -1710,7 +1710,7 @@ class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFi
17101710

17111711
class Meta:
17121712
model = ConsoleServerPort
1713-
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
1713+
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
17141714

17151715

17161716
class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
@@ -1723,6 +1723,7 @@ class Meta:
17231723
model = PowerPort
17241724
fields = (
17251725
'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
1726+
'cable_position',
17261727
)
17271728

17281729

@@ -1748,6 +1749,7 @@ class Meta:
17481749
model = PowerOutlet
17491750
fields = (
17501751
'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
1752+
'cable_position',
17511753
)
17521754

17531755

@@ -2055,7 +2057,7 @@ class Meta:
20552057
fields = (
20562058
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
20572059
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
2058-
'cable_id', 'cable_end',
2060+
'cable_id', 'cable_end', 'cable_position',
20592061
)
20602062

20612063
def filter_virtual_chassis_member_or_master(self, queryset, name, value):
@@ -2107,6 +2109,7 @@ class Meta:
21072109
model = FrontPort
21082110
fields = (
21092111
'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
2112+
'cable_position',
21102113
)
21112114

21122115

@@ -2120,6 +2123,7 @@ class Meta:
21202123
model = RearPort
21212124
fields = (
21222125
'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
2126+
'cable_position',
21232127
)
21242128

21252129

@@ -2316,6 +2320,9 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
23162320
status = django_filters.MultipleChoiceFilter(
23172321
choices=LinkStatusChoices
23182322
)
2323+
profile = django_filters.MultipleChoiceFilter(
2324+
choices=CableProfileChoices
2325+
)
23192326
color = django_filters.MultipleChoiceFilter(
23202327
choices=ColorChoices
23212328
)
@@ -2465,7 +2472,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
24652472

24662473
class Meta:
24672474
model = CableTermination
2468-
fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
2475+
fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id')
24692476

24702477

24712478
class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
@@ -2582,7 +2589,7 @@ class Meta:
25822589
model = PowerFeed
25832590
fields = (
25842591
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
2585-
'available_power', 'mark_connected', 'cable_end', 'description',
2592+
'available_power', 'mark_connected', 'cable_end', 'cable_position', 'description',
25862593
)
25872594

25882595
def search(self, queryset, name, value):

netbox/dcim/forms/bulk_edit.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,12 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
780780
required=False,
781781
initial=''
782782
)
783+
profile = forms.ChoiceField(
784+
label=_('Profile'),
785+
choices=add_blank_choice(CableProfileChoices),
786+
required=False,
787+
initial=''
788+
)
783789
tenant = DynamicModelChoiceField(
784790
label=_('Tenant'),
785791
queryset=Tenant.objects.all(),
@@ -808,11 +814,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
808814

809815
model = Cable
810816
fieldsets = (
811-
FieldSet('type', 'status', 'tenant', 'label', 'description'),
817+
FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'),
812818
FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
813819
)
814820
nullable_fields = (
815-
'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments',
821+
'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments',
816822
)
817823

818824

netbox/dcim/forms/bulk_import.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,6 +1461,12 @@ class CableImportForm(PrimaryModelImportForm):
14611461
required=False,
14621462
help_text=_('Connection status')
14631463
)
1464+
profile = CSVChoiceField(
1465+
label=_('Profile'),
1466+
choices=CableProfileChoices,
1467+
required=False,
1468+
help_text=_('Cable connection profile')
1469+
)
14641470
type = CSVChoiceField(
14651471
label=_('Type'),
14661472
choices=CableTypeChoices,
@@ -1491,8 +1497,8 @@ class Meta:
14911497
model = Cable
14921498
fields = [
14931499
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
1494-
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
1495-
'owner', 'comments', 'tags',
1500+
'side_b_name', 'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
1501+
'description', 'owner', 'comments', 'tags',
14961502
]
14971503

14981504
def __init__(self, data=None, *args, **kwargs):

0 commit comments

Comments
 (0)