Closes #20788: Cable profiles and and position mapping (#20802)

This commit is contained in:
Jeremy Stretch
2025-11-25 13:18:15 -05:00
committed by GitHub
parent cee2a5e0ed
commit 7cc7c7ab81
30 changed files with 1418 additions and 144 deletions

View File

@@ -21,6 +21,21 @@ The cable's operational status. Choices include:
* Planned
* Decommissioning
### Profile
!!! note "This field was introduced in NetBox v4.5."
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.
* Straight (single position)
* Straight (multi-position)
* Shuffle (2x2 MPO8)
* Shuffle (4x4 MPO8)
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.
The assignment of a cable profile is optional. If no profile is assigned, legacy tracing behavior will be preserved.
### Type
The cable's physical medium or classification.

View File

@@ -346,7 +346,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_position',
)
def search(self, queryset, name, value):

View File

@@ -0,0 +1,23 @@
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

@@ -25,15 +25,16 @@ class CableSerializer(PrimaryModelSerializer):
a_terminations = GenericObjectSerializer(many=True, required=False)
b_terminations = GenericObjectSerializer(many=True, required=False)
status = ChoiceField(choices=LinkStatusChoices, required=False)
profile = ChoiceField(choices=CableProfileChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
class Meta:
model = Cable
fields = [
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant',
'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'label', 'description')
@@ -60,10 +61,12 @@ class CableTerminationSerializer(NetBoxModelSerializer):
model = CableTermination
fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
'termination', 'created', 'last_updated',
'termination', 'position', 'created', 'last_updated',
]
read_only_fields = fields
brief_fields = ('id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id')
brief_fields = (
'id', 'url', 'display', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id',
)
class CablePathSerializer(serializers.ModelSerializer):

View File

@@ -0,0 +1,108 @@
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,
)
})
def get_mapped_position(self, side, position):
"""
Return the mapped position for a given cable end and position.
By default, assume all positions are symmetrical.
"""
return 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
)
# 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)
class StraightSingleCableProfile(BaseCableProfile):
a_max_connections = 1
b_max_connections = 1
class StraightMultiCableProfile(BaseCableProfile):
a_max_connections = None
b_max_connections = None
class Shuffle2x2MPO8CableProfile(BaseCableProfile):
a_max_connections = 8
b_max_connections = 8
_mapping = {
1: 1,
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)
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,
}
# 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)

View File

@@ -1717,6 +1717,19 @@ class PortTypeChoices(ChoiceSet):
# Cables/links
#
class CableProfileChoices(ChoiceSet):
STRAIGHT_SINGLE = 'straight-single'
STRAIGHT_MULTI = 'straight-multi'
SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8'
SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8'
CHOICES = (
(STRAIGHT_SINGLE, _('Straight (single position)')),
(STRAIGHT_MULTI, _('Straight (multi-position)')),
(SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')),
(SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')),
)
class CableTypeChoices(ChoiceSet):
# Copper - Twisted Pair (UTP/STP)

View File

@@ -20,6 +20,14 @@ RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
RACK_STARTING_UNIT_DEFAULT = 1
#
# Cables
#
CABLE_POSITION_MIN = 1
CABLE_POSITION_MAX = 1024
#
# RearPorts
#

View File

@@ -1699,7 +1699,7 @@ 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_position')
class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
@@ -1710,7 +1710,7 @@ 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_position')
class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
@@ -1723,6 +1723,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet,
model = PowerPort
fields = (
'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
'cable_position',
)
@@ -1748,6 +1749,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
model = PowerOutlet
fields = (
'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
'cable_position',
)
@@ -2055,7 +2057,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_position',
)
def filter_virtual_chassis_member_or_master(self, queryset, name, value):
@@ -2107,6 +2109,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
model = FrontPort
fields = (
'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
'cable_position',
)
@@ -2120,6 +2123,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
model = RearPort
fields = (
'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
'cable_position',
)
@@ -2316,6 +2320,9 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=LinkStatusChoices
)
profile = django_filters.MultipleChoiceFilter(
choices=CableProfileChoices
)
color = django_filters.MultipleChoiceFilter(
choices=ColorChoices
)
@@ -2465,7 +2472,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = CableTermination
fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id')
class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
@@ -2582,7 +2589,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_position', 'description',
)
def search(self, queryset, name, value):

View File

@@ -780,6 +780,12 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
required=False,
initial=''
)
profile = forms.ChoiceField(
label=_('Profile'),
choices=add_blank_choice(CableProfileChoices),
required=False,
initial=''
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
@@ -808,11 +814,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
model = Cable
fieldsets = (
FieldSet('type', 'status', 'tenant', 'label', 'description'),
FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'),
FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
)
nullable_fields = (
'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments',
'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments',
)

View File

@@ -1461,6 +1461,12 @@ class CableImportForm(PrimaryModelImportForm):
required=False,
help_text=_('Connection status')
)
profile = CSVChoiceField(
label=_('Profile'),
choices=CableProfileChoices,
required=False,
help_text=_('Cable connection profile')
)
type = CSVChoiceField(
label=_('Type'),
choices=CableTypeChoices,
@@ -1491,8 +1497,8 @@ class CableImportForm(PrimaryModelImportForm):
model = Cable
fields = [
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
'owner', 'comments', 'tags',
'side_b_name', 'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
'description', 'owner', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):

View File

@@ -1119,7 +1119,7 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('type', 'status', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -1175,6 +1175,11 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
required=False,
choices=add_blank_choice(LinkStatusChoices)
)
profile = forms.MultipleChoiceField(
label=_('Profile'),
required=False,
choices=add_blank_choice(CableProfileChoices)
)
color = ColorField(
label=_('Color'),
required=False

View File

@@ -807,8 +807,8 @@ class CableForm(TenancyForm, PrimaryModelForm):
class Meta:
model = Cable
fields = [
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'profile', 'tenant_group', 'tenant',
'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
]

View File

@@ -4,7 +4,7 @@ from django.db import connection
from django.db.models import Q
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath
from dcim.signals import create_cablepaths
ENDPOINT_MODELS = (
ConsolePort,
@@ -81,7 +81,7 @@ class Command(BaseCommand):
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0
for i, obj in enumerate(origins, start=1):
create_cablepath([obj])
create_cablepaths([obj])
if not i % 100:
self.draw_progress_bar(i * 100 / origins_count)
self.draw_progress_bar(100)

View File

@@ -0,0 +1,40 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0218_devicetype_device_count'),
]
operations = [
migrations.AddField(
model_name='cable',
name='profile',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='cabletermination',
name='position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AlterModelOptions(
name='cabletermination',
options={'ordering': ('cable', 'cable_end', 'position', 'pk')},
),
migrations.AddConstraint(
model_name='cabletermination',
constraint=models.UniqueConstraint(
fields=('cable', 'cable_end', 'position'),
name='dcim_cabletermination_unique_position'
),
),
]

View File

@@ -0,0 +1,107 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0219_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

@@ -3,6 +3,7 @@ import itertools
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.dispatch import Signal
from django.utils.translation import gettext_lazy as _
@@ -20,7 +21,7 @@ from utilities.fields import ColorField, GenericArrayForeignKey
from utilities.querysets import RestrictedQuerySet
from utilities.serialization import deserialize_object, serialize_object
from wireless.models import WirelessLink
from .device_components import FrontPort, RearPort, PathEndpoint
from .device_components import FrontPort, PathEndpoint, RearPort
__all__ = (
'Cable',
@@ -54,6 +55,12 @@ class Cable(PrimaryModel):
choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED
)
profile = models.CharField(
verbose_name=_('profile'),
max_length=50,
choices=CableProfileChoices,
blank=True,
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -92,7 +99,7 @@ class Cable(PrimaryModel):
null=True
)
clone_fields = ('tenant', 'type',)
clone_fields = ('tenant', 'type', 'profile')
class Meta:
ordering = ('pk',)
@@ -123,6 +130,16 @@ class Cable(PrimaryModel):
def get_status_color(self):
return LinkStatusChoices.colors.get(self.status)
@property
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,
}.get(self.profile)
def _get_x_terminations(self, side):
"""
Return the terminating objects for the given cable end (A or B).
@@ -195,6 +212,10 @@ class Cable(PrimaryModel):
if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
# Validate terminations against the assigned cable profile (if any)
if self.profile:
self.profile_class().clean(self)
if self._terminations_modified:
# Check that all termination objects for either end are of the same type
@@ -315,12 +336,14 @@ class Cable(PrimaryModel):
ct.delete()
# Save any new CableTerminations
for termination in self.a_terminations:
for i, termination in enumerate(self.a_terminations, start=1):
if not termination.pk or termination not in a_terminations:
CableTermination(cable=self, cable_end='A', termination=termination).save()
for termination in self.b_terminations:
position = i if self.profile and isinstance(termination, PathEndpoint) else None
CableTermination(cable=self, cable_end='A', position=position, termination=termination).save()
for i, termination in enumerate(self.b_terminations, start=1):
if not termination.pk or termination not in b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).save()
position = i if self.profile and isinstance(termination, PathEndpoint) else None
CableTermination(cable=self, cable_end='B', position=position, termination=termination).save()
class CableTermination(ChangeLoggedModel):
@@ -347,6 +370,14 @@ class CableTermination(ChangeLoggedModel):
ct_field='termination_type',
fk_field='termination_id'
)
position = models.PositiveIntegerField(
blank=True,
null=True,
validators=(
MinValueValidator(CABLE_POSITION_MIN),
MaxValueValidator(CABLE_POSITION_MAX)
)
)
# Cached associations to enable efficient filtering
_device = models.ForeignKey(
@@ -377,12 +408,16 @@ class CableTermination(ChangeLoggedModel):
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('cable', 'cable_end', 'pk')
ordering = ('cable', 'cable_end', 'position', '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'
),
)
verbose_name = _('cable termination')
verbose_name_plural = _('cable terminations')
@@ -446,6 +481,7 @@ class CableTermination(ChangeLoggedModel):
termination.snapshot()
termination.cable = self.cable
termination.cable_end = self.cable_end
termination.cable_position = self.position
termination.save()
def delete(self, *args, **kwargs):
@@ -455,6 +491,7 @@ class CableTermination(ChangeLoggedModel):
termination.snapshot()
termination.cable = None
termination.cable_end = None
termination.cable_position = None
termination.save()
super().delete(*args, **kwargs)
@@ -653,6 +690,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])
# Step 2: Determine the attached links (Cable or WirelessLink), if any
links = [termination.link for termination in terminations if termination.link is not None]
@@ -687,6 +727,14 @@ class CablePath(models.Model):
# Step 6: Determine the far-end terminations
if isinstance(links[0], Cable):
# 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]
# Legacy (positionless) behavior
else:
termination_type = ObjectType.objects.get_for_model(terminations[0])
local_cable_terminations = CableTermination.objects.filter(
termination_type=termination_type,

View File

@@ -175,6 +175,15 @@ class CabledObjectModel(models.Model):
blank=True,
null=True
)
cable_position = models.PositiveIntegerField(
verbose_name=_('cable position'),
blank=True,
null=True,
validators=(
MinValueValidator(CABLE_POSITION_MIN),
MaxValueValidator(CABLE_POSITION_MAX)
),
)
mark_connected = models.BooleanField(
verbose_name=_('mark connected'),
default=False,
@@ -194,14 +203,23 @@ class CabledObjectModel(models.Model):
def clean(self):
super().clean()
if self.cable and not self.cable_end:
if self.cable:
if not self.cable_end:
raise ValidationError({
"cable_end": _("Must specify cable end (A or B) when attaching a cable.")
})
if not self.cable_position:
raise ValidationError({
"cable_position": _("Must specify cable termination position when attaching a cable.")
})
if self.cable_end and not self.cable:
raise ValidationError({
"cable_end": _("Cable end must not be set without a cable.")
})
if self.cable_position and not self.cable:
raise ValidationError({
"cable_position": _("Cable termination position must not be set without a cable.")
})
if self.mark_connected and self.cable:
raise ValidationError({
"mark_connected": _("Cannot mark as connected with a cable attached.")

View File

@@ -11,7 +11,7 @@ from .models import (
VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
from .utils import create_cablepaths, rebuild_paths
COMPONENT_MODELS = (
ConsolePort,
@@ -114,7 +114,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
if not nodes:
continue
if isinstance(nodes[0], PathEndpoint):
create_cablepath(nodes)
create_cablepaths(nodes)
else:
rebuild_paths(nodes)

View File

@@ -108,6 +108,7 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
verbose_name=_('Site B')
)
status = columns.ChoiceFieldColumn()
profile = columns.ChoiceFieldColumn()
length = columns.TemplateColumn(
template_code=CABLE_LENGTH,
order_by=('_abs_length')
@@ -125,8 +126,8 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
model = Cable
fields = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'profile', 'type', 'tenant', 'tenant_group',
'color', 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',

View File

@@ -2396,6 +2396,7 @@ class CableTest(APIViewTestCases.APIViewTestCase):
'object_id': interfaces[14].pk,
}],
'label': 'Cable 4',
'profile': CableProfileChoices.STRAIGHT_SINGLE,
},
{
'a_terminations': [{
@@ -2407,6 +2408,7 @@ class CableTest(APIViewTestCases.APIViewTestCase):
'object_id': interfaces[15].pk,
}],
'label': 'Cable 5',
'profile': CableProfileChoices.STRAIGHT_SINGLE,
},
{
'a_terminations': [{
@@ -2418,6 +2420,7 @@ class CableTest(APIViewTestCases.APIViewTestCase):
'object_id': interfaces[16].pk,
}],
'label': 'Cable 6',
# No profile (legacy behavior)
},
]
@@ -2427,7 +2430,7 @@ class CableTerminationTest(
APIViewTestCases.ListObjectsViewTestCase,
):
model = CableTermination
brief_fields = ['cable', 'cable_end', 'display', 'id', 'termination_id', 'termination_type', 'url']
brief_fields = ['cable', 'cable_end', 'display', 'id', 'position', 'termination_id', 'termination_type', 'url']
@classmethod
def setUpTestData(cls):

View File

@@ -1,100 +1,21 @@
from django.test import TestCase
from circuits.models import *
from dcim.choices import LinkStatusChoices
from dcim.models import *
from dcim.svg import CableTraceSVG
from dcim.utils import object_to_path_node
from dcim.tests.utils import CablePathTestCase
from utilities.exceptions import AbortRequest
class CablePathTestCase(TestCase):
class LegacyCablePathTests(CablePathTestCase):
"""
Test NetBox's ability to trace and retrace CablePaths in response to data model changes. Tests are numbered
as follows:
Test NetBox's ability to trace and retrace CablePaths in response to data model changes, without cable profiles.
Tests are numbered as follows:
1XX: Test direct connections between different endpoint types
2XX: Test different cable topologies
3XX: Test responses to changes in existing objects
4XX: Test to exclude specific cable topologies
"""
@classmethod
def setUpTestData(cls):
# Create a single device that will hold all components
cls.site = Site.objects.create(name='Site', slug='site')
manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
role = DeviceRole.objects.create(name='Device Role', slug='device-role')
cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=role, name='Test Device')
cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel')
provider = Provider.objects.create(name='Provider', slug='provider')
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
def _get_cablepath(self, nodes, **kwargs):
"""
Return a given cable path
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
:return: The matching CablePath (if any)
"""
path = []
for step in nodes:
if type(step) in (list, tuple):
path.append([object_to_path_node(node) for node in step])
else:
path.append([object_to_path_node(step)])
return CablePath.objects.filter(path=path, **kwargs).first()
def assertPathExists(self, nodes, **kwargs):
"""
Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the
first matching CablePath, if found.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNotNone(cablepath, msg='CablePath not found')
return cablepath
def assertPathDoesNotExist(self, nodes, **kwargs):
"""
Assert that a specific CablePath does *not* exist.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNone(cablepath, msg='Unexpected CablePath found')
def assertPathIsSet(self, origin, cablepath, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param cablepath: The CablePath instance originating from this endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}"
self.assertEqual(origin._path_id, cablepath.pk, msg=msg)
def assertPathIsNotSet(self, origin, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!"
self.assertIsNone(origin._path_id, msg=msg)
def test_101_interface_to_interface(self):
"""
[IF1] --C1-- [IF2]
@@ -2270,6 +2191,55 @@ class CablePathTestCase(TestCase):
CableTraceSVG(interface1).render()
CableTraceSVG(interface2).render()
def test_223_single_path_via_multiple_pass_throughs_with_breakouts(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
[IF2] [FP2] [RP2] [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
# Create cables
cable1 = Cable(
a_terminations=[interface1, interface2],
b_terminations=[frontport1, frontport2]
)
cable1.save()
cable2 = Cable(
a_terminations=[rearport1, rearport2],
b_terminations=[interface3, interface4]
)
cable2.save()
# Validate paths
self.assertPathExists(
(
[interface1, interface2], cable1, [frontport1, frontport2],
[rearport1, rearport2], cable2, [interface3, interface4],
),
is_complete=True,
is_active=True
)
self.assertPathExists(
(
[interface3, interface4], cable2, [rearport1, rearport2],
[frontport1, frontport2], cable1, [interface1, interface2],
),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
def test_301_create_path_via_existing_cable(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]

View File

@@ -0,0 +1,788 @@
from unittest import skipIf
from circuits.models import CircuitTermination
from dcim.choices import CableProfileChoices
from dcim.models import *
from dcim.svg import CableTraceSVG
from dcim.tests.utils import CablePathTestCase
class CablePathTests(CablePathTestCase):
"""
Test the creation of CablePaths for Cables with different profiles applied.
Tests are numbered as follows:
1XX: Test direct connections using each profile
2XX: Topology tests replicated from the legacy test case and adapted to use profiles
"""
def test_101_cable_profile_straight_single(self):
"""
[IF1] --C1-- [IF2]
Cable profile: Straight single
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
# Create cable 1
cable1 = Cable(
profile=CableProfileChoices.STRAIGHT_SINGLE,
a_terminations=[interfaces[0]],
b_terminations=[interfaces[1]],
)
cable1.clean()
cable1.save()
path1 = self.assertPathExists(
(interfaces[0], cable1, interfaces[1]),
is_complete=True,
is_active=True
)
path2 = self.assertPathExists(
(interfaces[1], cable1, interfaces[0]),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interfaces[0].refresh_from_db()
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)
# 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_102_cable_profile_straight_multi(self):
"""
[IF1] --C1-- [IF3]
[IF2] [IF4]
Cable profile: Straight multi
"""
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'),
]
# Create cable 1
cable1 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[0], interfaces[1]],
b_terminations=[interfaces[2], interfaces[3]],
)
cable1.clean()
cable1.save()
path1 = self.assertPathExists(
(interfaces[0], cable1, interfaces[2]),
is_complete=True,
is_active=True
)
path2 = self.assertPathExists(
(interfaces[1], cable1, interfaces[3]),
is_complete=True,
is_active=True
)
path3 = self.assertPathExists(
(interfaces[2], cable1, interfaces[0]),
is_complete=True,
is_active=True
)
path4 = self.assertPathExists(
(interfaces[3], cable1, interfaces[1]),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
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.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)
# 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_103_cable_profile_2x2_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 (2x2 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 1:3'),
Interface.objects.create(device=self.device, name='Interface 1:4'),
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 2:3'),
Interface.objects.create(device=self.device, name='Interface 2:4'),
# B side
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 3:3'),
Interface.objects.create(device=self.device, name='Interface 3:4'),
Interface.objects.create(device=self.device, name='Interface 4:1'),
Interface.objects.create(device=self.device, name='Interface 4:2'),
Interface.objects.create(device=self.device, name='Interface 4:3'),
Interface.objects.create(device=self.device, name='Interface 4:4'),
]
# Create cable 1
cable1 = Cable(
profile=CableProfileChoices.SHUFFLE_2X2_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[9]), 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[13]), is_complete=True, is_active=True
),
self.assertPathExists(
(interfaces[4], cable1, interfaces[10]), 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[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
),
]
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_position, (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_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_position, (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]
[IF2] [IF4]
"""
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'),
]
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device,
name='Front Port 1',
rear_port=rearport1,
rear_port_position=1
)
# Create cables
cable1 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[0], interfaces[1]],
b_terminations=[frontport1],
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[rearport1],
b_terminations=[interfaces[2], interfaces[3]]
)
cable2.clean()
cable2.save()
paths = [
self.assertPathExists(
(interfaces[0], cable1, frontport1, rearport1, cable2, interfaces[2]),
is_complete=True,
is_active=True
),
self.assertPathExists(
(interfaces[1], cable1, frontport1, rearport1, cable2, interfaces[3]),
is_complete=True,
is_active=True
),
self.assertPathExists(
(interfaces[2], cable2, rearport1, frontport1, cable1, interfaces[0]),
is_complete=True,
is_active=True
),
self.assertPathExists(
(interfaces[3], cable2, rearport1, frontport1, cable1, interfaces[1]),
is_complete=True,
is_active=True
),
]
self.assertEqual(CablePath.objects.count(), 4)
for interface in interfaces:
interface.refresh_from_db()
self.assertPathIsSet(interfaces[0], paths[0])
self.assertPathIsSet(interfaces[1], paths[1])
self.assertPathIsSet(interfaces[2], paths[2])
self.assertPathIsSet(interfaces[3], paths[3])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
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]
"""
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'),
]
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', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
)
frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
)
# Create cables
cable1 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[0], interfaces[1]],
b_terminations=[frontport1_1]
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[2], interfaces[3]],
b_terminations=[frontport1_2]
)
cable2.clean()
cable2.save()
cable3 = Cable(
profile=CableProfileChoices.STRAIGHT_SINGLE,
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]]
)
cable4.clean()
cable4.save()
cable5 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[frontport2_2],
b_terminations=[interfaces[6], interfaces[7]]
)
cable5.clean()
cable5.save()
paths = [
self.assertPathExists(
(
interfaces[0], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4,
interfaces[4],
),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(
interfaces[1], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4,
interfaces[5],
),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(
interfaces[2], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5,
interfaces[6],
),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(
interfaces[3], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5,
interfaces[7],
),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(
interfaces[4], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1,
interfaces[0],
),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(
interfaces[5], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1,
interfaces[1],
),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(
interfaces[6], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2,
interfaces[2],
),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(
interfaces[7], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2,
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], paths[0])
self.assertPathIsSet(interfaces[1], paths[1])
self.assertPathIsSet(interfaces[2], paths[2])
self.assertPathIsSet(interfaces[3], paths[3])
self.assertPathIsSet(interfaces[4], paths[4])
self.assertPathIsSet(interfaces[5], paths[5])
self.assertPathIsSet(interfaces[6], paths[6])
self.assertPathIsSet(interfaces[7], paths[7])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_212_interface_to_interface_via_circuit_with_breakouts(self):
"""
[IF1] --C1-- [CT1] [CT2] --C2-- [IF3]
[IF2] [IF4]
"""
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'),
]
circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='Z'
)
# Create cables
cable1 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[0], interfaces[1]],
b_terminations=[circuittermination1]
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[circuittermination2],
b_terminations=[interfaces[2], interfaces[3]]
)
cable2.clean()
cable2.save()
# Check for two complete paths in either direction
paths = [
self.assertPathExists(
(interfaces[0], cable1, circuittermination1, circuittermination2, cable2, interfaces[2]),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(interfaces[1], cable1, circuittermination1, circuittermination2, cable2, interfaces[3]),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(interfaces[2], cable2, circuittermination2, circuittermination1, cable1, interfaces[0]),
is_complete=True,
is_active=True,
),
self.assertPathExists(
(interfaces[3], cable2, circuittermination2, circuittermination1, cable1, interfaces[1]),
is_complete=True,
is_active=True,
),
]
self.assertEqual(CablePath.objects.count(), 4)
for interface in interfaces:
interface.refresh_from_db()
self.assertPathIsSet(interfaces[0], paths[0])
self.assertPathIsSet(interfaces[1], paths[1])
self.assertPathIsSet(interfaces[2], paths[2])
self.assertPathIsSet(interfaces[3], paths[3])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
def test_217_interface_to_interface_via_rear_ports(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP3] [FP3] --C3-- [IF2]
[FP2] [RP2] [RP4] [FP4]
"""
interfaces = [
Interface.objects.create(device=self.device, name='Interface 1'),
Interface.objects.create(device=self.device, name='Interface 2'),
]
rear_ports = [
RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1),
RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1),
RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1),
RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1),
]
front_ports = [
FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1
),
FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1
),
FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1
),
FrontPort.objects.create(
device=self.device, name='Front Port 4', rear_port=rear_ports[3], rear_port_position=1
),
]
# Create cables
cable1 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[0]],
b_terminations=[front_ports[0], front_ports[1]]
)
cable1.clean()
cable1.save()
cable2 = Cable(
a_terminations=[rear_ports[0], rear_ports[1]],
b_terminations=[rear_ports[2], rear_ports[3]]
)
cable2.clean()
cable2.save()
cable3 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[1]],
b_terminations=[front_ports[2], front_ports[3]]
)
cable3.clean()
cable3.save()
# Check for one complete path in either direction
paths = [
self.assertPathExists(
(
interfaces[0], cable1, (front_ports[0], front_ports[1]), (rear_ports[0], rear_ports[1]), cable2,
(rear_ports[2], rear_ports[3]), (front_ports[2], front_ports[3]), cable3, interfaces[1]
),
is_complete=True
),
self.assertPathExists(
(
interfaces[1], cable3, (front_ports[2], front_ports[3]), (rear_ports[2], rear_ports[3]), cable2,
(rear_ports[0], rear_ports[1]), (front_ports[0], front_ports[1]), cable1, interfaces[0]
),
is_complete=True
),
]
self.assertEqual(CablePath.objects.count(), 2)
for interface in interfaces:
interface.refresh_from_db()
self.assertPathIsSet(interfaces[0], paths[0])
self.assertPathIsSet(interfaces[1], paths[1])
# Test SVG generation
CableTraceSVG(interfaces[0]).render()
# TODO: Revisit this test under FR #20564
@skipIf(True, "Waiting for FR #20564")
def test_223_single_path_via_multiple_pass_throughs_with_breakouts(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
[IF2] [FP2] [RP2] [IF4]
"""
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'),
]
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
# Create cables
cable1 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[interfaces[0], interfaces[1]],
b_terminations=[frontport1, frontport2]
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.STRAIGHT_MULTI,
a_terminations=[rearport1, rearport2],
b_terminations=[interfaces[2], interfaces[3]]
)
cable2.clean()
cable2.save()
for path in CablePath.objects.all():
print(f'{path}: {path.path_objects}')
# Validate paths
self.assertPathExists(
(interfaces[0], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[2]),
is_complete=True,
is_active=True
)
self.assertPathExists(
(interfaces[1], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[3]),
is_complete=True,
is_active=True
)
self.assertPathExists(
(interfaces[2], cable2, [rearport1, rearport2], [frontport1, frontport2], cable1, interfaces[0]),
is_complete=True,
is_active=True
)
self.assertPathExists(
(interfaces[3], cable2, [rearport1, rearport2], [frontport1, frontport2], cable1, interfaces[1]),
is_complete=True,
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)

View File

@@ -0,0 +1,88 @@
from django.test import TestCase
from circuits.models import *
from dcim.models import *
from dcim.utils import object_to_path_node
__all__ = (
'CablePathTestCase',
)
class CablePathTestCase(TestCase):
"""
Base class for test cases for cable paths.
"""
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
role = DeviceRole.objects.create(name='Device Role', slug='device-role')
provider = Provider.objects.create(name='Provider', slug='provider')
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
# Create reusable test objects
cls.site = Site.objects.create(name='Site', slug='site')
cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=role, name='Test Device')
cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel')
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
def _get_cablepath(self, nodes, **kwargs):
"""
Return a given cable path
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
:return: The matching CablePath (if any)
"""
path = []
for step in nodes:
if type(step) in (list, tuple):
path.append([object_to_path_node(node) for node in step])
else:
path.append([object_to_path_node(step)])
return CablePath.objects.filter(path=path, **kwargs).first()
def assertPathExists(self, nodes, **kwargs):
"""
Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the
first matching CablePath, if found.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNotNone(cablepath, msg='CablePath not found')
return cablepath
def assertPathDoesNotExist(self, nodes, **kwargs):
"""
Assert that a specific CablePath does *not* exist.
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
"""
cablepath = self._get_cablepath(nodes, **kwargs)
self.assertIsNone(cablepath, msg='Unexpected CablePath found')
def assertPathIsSet(self, origin, cablepath, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param cablepath: The CablePath instance originating from this endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}"
self.assertEqual(origin._path_id, cablepath.pk, msg=msg)
def assertPathIsNotSet(self, origin, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!"
self.assertIsNone(origin._path_id, msg=msg)

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import router, transaction
@@ -31,16 +33,21 @@ def path_node_to_object(repr):
return ct.model_class().objects.filter(pk=object_id).first()
def create_cablepath(terminations):
def create_cablepaths(objects):
"""
Create CablePaths for all paths originating from the specified set of nodes.
:param terminations: Iterable of CableTermination objects
:param objects: Iterable of cabled objects (e.g. Interfaces)
"""
from dcim.models import CablePath
cp = CablePath.from_origin(terminations)
if cp:
# Arrange objects by cable position. All objects with a null position are grouped together.
origins = defaultdict(list)
for obj in objects:
origins[obj.cable_position].append(obj)
for position, objects in origins.items():
if cp := CablePath.from_origin(objects):
cp.save()
@@ -56,7 +63,7 @@ def rebuild_paths(terminations):
with transaction.atomic(using=router.db_for_write(CablePath)):
for cp in cable_paths:
cp.delete()
create_cablepath(cp.origins)
create_cablepaths(cp.origins)
def update_interface_bridges(device, interface_templates, module=None):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,11 @@ export function getPlugins(element: HTMLSelectElement): object {
};
}
// Enable drag-and-drop reordering of items on multi-select fields
if (element.hasAttribute('multiple')) {
plugins.drag_drop = {};
}
return {
plugins: plugins,
};

View File

@@ -19,6 +19,10 @@
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Profile" %}</th>
<td>{% badge object.get_profile_display %}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>

View File

@@ -53,6 +53,7 @@
<h2 class="col-9 offset-3">{% trans "Cable" %}</h2>
</div>
{% render_field form.status %}
{% render_field form.profile %}
{% render_field form.type %}
{% render_field form.label %}
{% render_field form.description %}

View File

@@ -5,7 +5,7 @@ from django.dispatch import receiver
from dcim.exceptions import UnsupportedCablePath
from dcim.models import CablePath, Interface
from dcim.utils import create_cablepath
from dcim.utils import create_cablepaths
from utilities.exceptions import AbortRequest
from .models import WirelessLink
@@ -37,7 +37,7 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs):
if created:
for interface in (instance.interface_a, instance.interface_b):
try:
create_cablepath([interface])
create_cablepaths([interface])
except UnsupportedCablePath as e:
raise AbortRequest(e)