Additional work for FR #20788 (#20973)
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
CI / build (20.x, 3.14) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run

This commit is contained in:
Jeremy Stretch
2025-12-15 15:41:07 -05:00
committed by GitHub
parent 3140060f21
commit 875e3e7979
20 changed files with 1611 additions and 517 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = [

View File

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

View File

@@ -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,14 +178,23 @@ 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_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'),
@@ -210,17 +220,30 @@ 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:
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_position and not self.cable:
if self.cable_connector:
raise ValidationError({
"cable_position": _("Cable termination position must not be set without a cable.")
"cable_connector": _("Cable connector must not be set without a cable.")
})
if self.mark_connected and self.cable:
if self.cable_positions:
raise ValidationError({
"mark_connected": _("Cannot mark as connected with a cable attached.")
"cable_positions": _("Cable termination positions must not be set without a cable.")
})
@property
@@ -256,6 +279,22 @@ class CabledObjectModel(models.Model):
return None
return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
def set_cable_termination(self, termination):
"""Save attributes from the given CableTermination on the terminating object."""
self.cable = termination.cable
self.cable_end = termination.cable_end
self.cable_connector = termination.connector
self.cable_positions = termination.positions
set_cable_termination.alters_data = True
def clear_cable_termination(self, termination):
"""Clear all cable termination attributes from the terminating object."""
self.cable = None
self.cable_end = None
self.cable_connector = None
self.cable_positions = None
clear_cable_termination.alters_data = True
class PathEndpoint(models.Model):
"""

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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):

View File

@@ -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()