mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-14 04:19:36 -06:00
Initial work on FR #20788 (cable profiles)
This commit is contained in:
parent
cee2a5e0ed
commit
0901694b2b
23
netbox/circuits/migrations/0054_cable_position.py
Normal file
23
netbox/circuits/migrations/0054_cable_position.py
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
118
netbox/dcim/cable_profiles.py
Normal file
118
netbox/dcim/cable_profiles.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
#
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
40
netbox/dcim/migrations/0218_cable_positions.py
Normal file
40
netbox/dcim/migrations/0218_cable_positions.py
Normal 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'
|
||||
),
|
||||
),
|
||||
]
|
||||
107
netbox/dcim/migrations/0219_cable_position.py
Normal file
107
netbox/dcim/migrations/0219_cable_position.py
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,23 +729,31 @@ class CablePath(models.Model):
|
||||
|
||||
# Step 6: Determine the far-end terminations
|
||||
if isinstance(links[0], Cable):
|
||||
termination_type = ObjectType.objects.get_for_model(terminations[0])
|
||||
local_cable_terminations = CableTermination.objects.filter(
|
||||
termination_type=termination_type,
|
||||
termination_id__in=[t.pk for t in terminations]
|
||||
)
|
||||
# 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]
|
||||
|
||||
q_filter = Q()
|
||||
for lct in local_cable_terminations:
|
||||
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||
# Legacy (positionless) behavior
|
||||
else:
|
||||
termination_type = ObjectType.objects.get_for_model(terminations[0])
|
||||
local_cable_terminations = CableTermination.objects.filter(
|
||||
termination_type=termination_type,
|
||||
termination_id__in=[t.pk for t in terminations]
|
||||
)
|
||||
|
||||
# Make sure this filter has been populated; if not, we have probably been given invalid data
|
||||
if not q_filter:
|
||||
break
|
||||
q_filter = Q()
|
||||
for lct in local_cable_terminations:
|
||||
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||
|
||||
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||
# Make sure this filter has been populated; if not, we have probably been given invalid data
|
||||
if not q_filter:
|
||||
break
|
||||
|
||||
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||
else:
|
||||
# WirelessLink
|
||||
remote_terminations = [
|
||||
|
||||
@ -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:
|
||||
raise ValidationError({
|
||||
"cable_end": _("Must specify cable end (A or B) when attaching a cable.")
|
||||
})
|
||||
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.")
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,17 +33,22 @@ 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:
|
||||
cp.save()
|
||||
# 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()
|
||||
|
||||
|
||||
def rebuild_paths(terminations):
|
||||
@ -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):
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user