diff --git a/netbox/circuits/migrations/0054_cable_position.py b/netbox/circuits/migrations/0054_cable_position.py new file mode 100644 index 000000000..cedc8813b --- /dev/null +++ b/netbox/circuits/migrations/0054_cable_position.py @@ -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), + ], + ), + ), + ] diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py index 5f3017368..95324b876 100644 --- a/netbox/dcim/api/serializers_/cables.py +++ b/netbox/dcim/api/serializers_/cables.py @@ -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): diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py new file mode 100644 index 000000000..35e262365 --- /dev/null +++ b/netbox/dcim/cable_profiles.py @@ -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) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index cca2dd0bb..8f59d47ba 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 387b4d6a7..92b58cef3 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0fd7631ac..604369d56 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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 ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 6d1e4d7cc..9aa076a6a 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 127511779..ba0b44b0d 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1197002a5..f874ce916 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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 diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index a774bb90f..75a827476 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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', ] diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index 592aeb6a7..ded4e1780 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -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) diff --git a/netbox/dcim/migrations/0218_cable_positions.py b/netbox/dcim/migrations/0218_cable_positions.py new file mode 100644 index 000000000..662a7d014 --- /dev/null +++ b/netbox/dcim/migrations/0218_cable_positions.py @@ -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' + ), + ), + ] diff --git a/netbox/dcim/migrations/0219_cable_position.py b/netbox/dcim/migrations/0219_cable_position.py new file mode 100644 index 000000000..7b67eebd7 --- /dev/null +++ b/netbox/dcim/migrations/0219_cable_position.py @@ -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), + ], + ), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 73ea08ff4..530863066 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -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 = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3e801a8e9..9ed982da4 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -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.") diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 9295ddbdb..eb1825c1a 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -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) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index a4e3be269..72220591e 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -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', diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index a03790ea2..2380fbd0d 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -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): diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 1a618155f..8e685c514 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -19,6 +19,10 @@