Initial work on FR #20788 (cable profiles)

This commit is contained in:
Jeremy Stretch 2025-11-13 17:06:55 -05:00
parent cee2a5e0ed
commit 0901694b2b
21 changed files with 484 additions and 50 deletions

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', 'termination_type', 'termination_id', 'position',
)
class CablePathSerializer(serializers.ModelSerializer):

View File

@ -0,0 +1,118 @@
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
# Number of A & B terminations must match
symmetrical = True
# Whether to pop the position stack when tracing a cable from this end
pop_stack_a_side = True
pop_stack_b_side = True
def clean(self, cable):
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.a_max_connections,
)
})
if self.symmetrical and len(cable.a_terminations) != len(cable.b_terminations):
raise ValidationError({
'b_terminations': _(
'Number of A and B terminations must be equal for profile {profile}'
).format(
profile=cable.get_profile_display(),
)
})
def get_mapped_position(self, position):
return position
def get_peer_terminations(self, terminations, position_stack):
local_end = terminations[0].cable_end
position = None
# Pop the position stack if necessary
if (local_end == 'A' and self.pop_stack_a_side) or (local_end == 'B' and self.pop_stack_b_side):
position = position_stack.pop()[0]
qs = CableTermination.objects.filter(
cable=terminations[0].cable,
cable_end=terminations[0].opposite_cable_end
)
if position is not None:
qs = qs.filter(position=self.get_mapped_position(position))
return qs
class StraightSingleCableProfile(BaseCableProfile):
a_max_connections = 1
b_max_connections = 1
class StraightMultiCableProfile(BaseCableProfile):
a_max_connections = None
b_max_connections = None
class AToManyCableProfile(BaseCableProfile):
a_max_connections = 1
b_max_connections = None
symmetrical = False
pop_stack_a_side = False
class BToManyCableProfile(BaseCableProfile):
a_max_connections = None
b_max_connections = 1
symmetrical = False
pop_stack_b_side = False
class Shuffle4x4CableProfile(BaseCableProfile):
a_max_connections = 4
b_max_connections = 4
def get_mapped_position(self, position):
return {
1: 1,
2: 3,
3: 2,
4: 4,
}.get(position)
class Shuffle8x8CableProfile(BaseCableProfile):
a_max_connections = 8
b_max_connections = 8
def get_mapped_position(self, position):
return {
1: 1,
2: 2,
3: 5,
4: 6,
5: 3,
6: 4,
7: 7,
8: 8,
}.get(position)

View File

@ -1717,6 +1717,40 @@ class PortTypeChoices(ChoiceSet):
# Cables/links
#
class CableProfileChoices(ChoiceSet):
STRAIGHT_SINGLE = 'straight-single'
STRAIGHT_MULTI = 'straight-multi'
A_TO_MANY = 'a-to-many'
B_TO_MANY = 'b-to-many'
SHUFFLE_4X4 = 'shuffle-4x4'
SHUFFLE_8X8 = 'shuffle-8x8'
CHOICES = (
(STRAIGHT_SINGLE, _('Straight (single position)')),
(STRAIGHT_MULTI, _('Straight (multi-position)')),
# TODO: Better names for many-to-one profiles?
(A_TO_MANY, _('A to many')),
(B_TO_MANY, _('B to many')),
(SHUFFLE_4X4, _('Shuffle (4x4)')),
(SHUFFLE_8X8, _('Shuffle (8x8)')),
)
# TODO: Move these designations into the profiles
A_SIDE_NUMBERED = (
STRAIGHT_SINGLE,
STRAIGHT_MULTI,
B_TO_MANY,
SHUFFLE_4X4,
SHUFFLE_8X8,
)
B_SIDE_NUMBERED = (
STRAIGHT_SINGLE,
STRAIGHT_MULTI,
A_TO_MANY,
SHUFFLE_4X4,
SHUFFLE_8X8,
)
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
#
CABLETERMINATION_POSITION_MIN = 1
CABLETERMINATION_POSITION_MAX = 1024
#
# RearPorts
#

View File

@ -2316,6 +2316,9 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=LinkStatusChoices
)
profile = django_filters.MultipleChoiceFilter(
choices=CableProfileChoices
)
color = django_filters.MultipleChoiceFilter(
choices=ColorChoices
)

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', '0217_owner'),
]
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', '0218_cable_positions'),
]
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 _
@ -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,18 @@ 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.A_TO_MANY: cable_profiles.AToManyCableProfile,
CableProfileChoices.B_TO_MANY: cable_profiles.BToManyCableProfile,
CableProfileChoices.SHUFFLE_4X4: cable_profiles.Shuffle4x4CableProfile,
CableProfileChoices.SHUFFLE_8X8: cable_profiles.Shuffle8x8CableProfile,
}.get(self.profile)
def _get_x_terminations(self, side):
"""
Return the terminating objects for the given cable end (A or B).
@ -195,6 +214,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 +338,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 in CableProfileChoices.A_SIDE_NUMBERED 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 in CableProfileChoices.B_SIDE_NUMBERED else None
CableTermination(cable=self, cable_end='B', position=position, termination=termination).save()
class CableTermination(ChangeLoggedModel):
@ -347,6 +372,14 @@ class CableTermination(ChangeLoggedModel):
ct_field='termination_type',
fk_field='termination_id'
)
position = models.PositiveIntegerField(
blank=True,
null=True,
validators=(
MinValueValidator(CABLETERMINATION_POSITION_MIN),
MaxValueValidator(CABLETERMINATION_POSITION_MAX)
)
)
# Cached associations to enable efficient filtering
_device = models.ForeignKey(
@ -377,12 +410,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 +483,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 +493,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 +692,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 +729,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(CABLETERMINATION_POSITION_MIN),
MaxValueValidator(CABLETERMINATION_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

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

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)