From 0901694b2b6f731e5a9ba669e56d6b353cdc7b6c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Nov 2025 17:06:55 -0500 Subject: [PATCH 01/51] Initial work on FR #20788 (cable profiles) --- .../migrations/0054_cable_position.py | 23 ++++ netbox/dcim/api/serializers_/cables.py | 13 +- netbox/dcim/cable_profiles.py | 118 ++++++++++++++++++ netbox/dcim/choices.py | 34 +++++ netbox/dcim/constants.py | 8 ++ netbox/dcim/filtersets.py | 3 + netbox/dcim/forms/bulk_edit.py | 10 +- netbox/dcim/forms/bulk_import.py | 10 +- netbox/dcim/forms/filtersets.py | 7 +- netbox/dcim/forms/model_forms.py | 4 +- .../dcim/management/commands/trace_paths.py | 4 +- .../dcim/migrations/0218_cable_positions.py | 40 ++++++ netbox/dcim/migrations/0219_cable_position.py | 107 ++++++++++++++++ netbox/dcim/models/cables.py | 90 ++++++++++--- netbox/dcim/models/device_components.py | 26 +++- netbox/dcim/signals.py | 4 +- netbox/dcim/tables/cables.py | 5 +- netbox/dcim/utils.py | 19 ++- netbox/templates/dcim/cable.html | 4 + netbox/templates/dcim/htmx/cable_edit.html | 1 + netbox/wireless/signals.py | 4 +- 21 files changed, 484 insertions(+), 50 deletions(-) create mode 100644 netbox/circuits/migrations/0054_cable_position.py create mode 100644 netbox/dcim/cable_profiles.py create mode 100644 netbox/dcim/migrations/0218_cable_positions.py create mode 100644 netbox/dcim/migrations/0219_cable_position.py 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 @@ {% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Profile" %} + {% badge object.get_profile_display %} + {% trans "Tenant" %} diff --git a/netbox/templates/dcim/htmx/cable_edit.html b/netbox/templates/dcim/htmx/cable_edit.html index 6de42fc49..4e4043c4d 100644 --- a/netbox/templates/dcim/htmx/cable_edit.html +++ b/netbox/templates/dcim/htmx/cable_edit.html @@ -53,6 +53,7 @@

{% trans "Cable" %}

{% render_field form.status %} + {% render_field form.profile %} {% render_field form.type %} {% render_field form.label %} {% render_field form.description %} diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index b1a2d2feb..ce55e55f3 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -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) From a20ac40b487ca188fbc2a204c1facfad942f0071 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 08:51:49 -0500 Subject: [PATCH 02/51] Add missing filters for cable_position --- netbox/circuits/filtersets.py | 2 +- netbox/dcim/filtersets.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 9d95037ec..77f713899 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -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): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 604369d56..9c161aa54 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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', ) @@ -2468,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): @@ -2585,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): From 481811e487ed4999ad3a8e0c39c7425e5a7dee16 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 08:59:49 -0500 Subject: [PATCH 03/51] Misc cleanup --- netbox/dcim/cable_profiles.py | 15 +-------------- netbox/dcim/choices.py | 12 ++++-------- netbox/dcim/constants.py | 4 ++-- netbox/dcim/models/cables.py | 7 +++---- netbox/dcim/models/device_components.py | 4 ++-- 5 files changed, 12 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 35e262365..353b64626 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -88,20 +88,7 @@ class BToManyCableProfile(BaseCableProfile): 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): +class Shuffle2x2MPOCableProfile(BaseCableProfile): a_max_connections = 8 b_max_connections = 8 diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8f59d47ba..3d8510eb3 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1722,8 +1722,7 @@ class CableProfileChoices(ChoiceSet): STRAIGHT_MULTI = 'straight-multi' A_TO_MANY = 'a-to-many' B_TO_MANY = 'b-to-many' - SHUFFLE_4X4 = 'shuffle-4x4' - SHUFFLE_8X8 = 'shuffle-8x8' + SHUFFLE_2X2_MPO = 'shuffle-2x2-mpo' CHOICES = ( (STRAIGHT_SINGLE, _('Straight (single position)')), @@ -1731,8 +1730,7 @@ class CableProfileChoices(ChoiceSet): # 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)')), + (SHUFFLE_2X2_MPO, _('Shuffle (2x2 MPO)')), ) # TODO: Move these designations into the profiles @@ -1740,15 +1738,13 @@ class CableProfileChoices(ChoiceSet): STRAIGHT_SINGLE, STRAIGHT_MULTI, B_TO_MANY, - SHUFFLE_4X4, - SHUFFLE_8X8, + SHUFFLE_2X2_MPO, ) B_SIDE_NUMBERED = ( STRAIGHT_SINGLE, STRAIGHT_MULTI, A_TO_MANY, - SHUFFLE_4X4, - SHUFFLE_8X8, + SHUFFLE_2X2_MPO, ) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 92b58cef3..5e4311c13 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -24,8 +24,8 @@ RACK_STARTING_UNIT_DEFAULT = 1 # Cables # -CABLETERMINATION_POSITION_MIN = 1 -CABLETERMINATION_POSITION_MAX = 1024 +CABLE_POSITION_MIN = 1 +CABLE_POSITION_MAX = 1024 # diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 530863066..9bfdf0061 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -138,8 +138,7 @@ class Cable(PrimaryModel): 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, + CableProfileChoices.SHUFFLE_2X2_MPO: cable_profiles.Shuffle2x2MPOCableProfile, }.get(self.profile) def _get_x_terminations(self, side): @@ -376,8 +375,8 @@ class CableTermination(ChangeLoggedModel): blank=True, null=True, validators=( - MinValueValidator(CABLETERMINATION_POSITION_MIN), - MaxValueValidator(CABLETERMINATION_POSITION_MAX) + MinValueValidator(CABLE_POSITION_MIN), + MaxValueValidator(CABLE_POSITION_MAX) ) ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9ed982da4..8c9acc48f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -180,8 +180,8 @@ class CabledObjectModel(models.Model): blank=True, null=True, validators=( - MinValueValidator(CABLETERMINATION_POSITION_MIN), - MaxValueValidator(CABLETERMINATION_POSITION_MAX) + MinValueValidator(CABLE_POSITION_MIN), + MaxValueValidator(CABLE_POSITION_MAX) ), ) mark_connected = models.BooleanField( From 7edea73f8503b7b5bd0159333dea73d3f022d561 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 10:28:43 -0500 Subject: [PATCH 04/51] Add initial cable path tests for profiles --- netbox/dcim/cable_profiles.py | 2 +- netbox/dcim/tests/test_cablepaths.py | 87 +----- netbox/dcim/tests/test_cablepaths2.py | 371 ++++++++++++++++++++++++++ netbox/dcim/tests/utils.py | 88 ++++++ 4 files changed, 464 insertions(+), 84 deletions(-) create mode 100644 netbox/dcim/tests/test_cablepaths2.py create mode 100644 netbox/dcim/tests/utils.py diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 353b64626..8433d29b9 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -32,7 +32,7 @@ class BaseCableProfile: 'Maximum B side connections for profile {profile}: {max}' ).format( profile=cable.get_profile_display(), - max=self.a_max_connections, + max=self.b_max_connections, ) }) if self.symmetrical and len(cable.a_terminations) != len(cable.b_terminations): diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 399478e70..874b68340 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -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] diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py new file mode 100644 index 000000000..eb8994349 --- /dev/null +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -0,0 +1,371 @@ +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 + """ + + 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_a_to_many(self): + """ + [IF1] --C1-- [IF2] + [IF3] + [IF4] + + Cable profile: A to many + """ + 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.A_TO_MANY, + a_terminations=[interfaces[0]], + b_terminations=[interfaces[1], interfaces[2], interfaces[3]], + ) + cable1.clean() + cable1.save() + + # A-to-B path leads to all interfaces + path1 = self.assertPathExists( + (interfaces[0], cable1, [interfaces[1], interfaces[2], interfaces[3]]), + is_complete=True, + is_active=True + ) + # B-to-A paths are incomplete because A side has null position + path2 = self.assertPathExists( + (interfaces[1], cable1, []), + is_complete=False, + is_active=True + ) + path3 = self.assertPathExists( + (interfaces[2], cable1, []), + is_complete=False, + is_active=True + ) + path4 = self.assertPathExists( + (interfaces[3], cable1, []), + is_complete=False, + 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.assertIsNone(interfaces[0].cable_position) + self.assertEqual(interfaces[1].cable_position, 1) + self.assertEqual(interfaces[2].cable_position, 2) + self.assertEqual(interfaces[3].cable_position, 3) + + # 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_b_to_many(self): + """ + [IF1] --C1-- [IF4] + [IF2] + [IF3] + + Cable profile: B to many + """ + 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.B_TO_MANY, + a_terminations=[interfaces[0], interfaces[1], interfaces[2]], + b_terminations=[interfaces[3]], + ) + cable1.clean() + cable1.save() + + # A-to-B paths are incomplete because A side has null position + path1 = self.assertPathExists( + (interfaces[0], cable1, []), + is_complete=False, + is_active=True + ) + path2 = self.assertPathExists( + (interfaces[1], cable1, []), + is_complete=False, + is_active=True + ) + path3 = self.assertPathExists( + (interfaces[2], cable1, []), + is_complete=False, + is_active=True + ) + # B-to-A path leads to all interfaces + path4 = self.assertPathExists( + (interfaces[3], cable1, [interfaces[0], interfaces[1], interfaces[2]]), + 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, 3) + self.assertIsNone(interfaces[3].cable_position) + + # 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_105_cable_profile_2x2_mpo(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 MPO) + """ + interfaces = [ + 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'), + 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_MPO, + 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) diff --git a/netbox/dcim/tests/utils.py b/netbox/dcim/tests/utils.py new file mode 100644 index 000000000..575034201 --- /dev/null +++ b/netbox/dcim/tests/utils.py @@ -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) From fe95d89db37d25e59f817186cfbc4c6114e38be1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 13:38:47 -0500 Subject: [PATCH 05/51] Fix test --- netbox/dcim/api/serializers_/cables.py | 2 +- netbox/dcim/tests/test_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/api/serializers_/cables.py b/netbox/dcim/api/serializers_/cables.py index 95324b876..0effbd536 100644 --- a/netbox/dcim/api/serializers_/cables.py +++ b/netbox/dcim/api/serializers_/cables.py @@ -65,7 +65,7 @@ class CableTerminationSerializer(NetBoxModelSerializer): ] read_only_fields = fields brief_fields = ( - 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'position', + 'id', 'url', 'display', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id', ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 938a625b0..ea9f7a84a 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2427,7 +2427,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): From 2fe5323dd2eecc6afb680de176c3714462000c36 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 15:45:44 -0500 Subject: [PATCH 06/51] Add topology tests for cable profiles --- netbox/dcim/cable_profiles.py | 11 +- netbox/dcim/tests/test_cablepaths2.py | 389 +++++++++++++++++++++++++- 2 files changed, 383 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 8433d29b9..b6fea1cc4 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -53,7 +53,12 @@ class BaseCableProfile: # 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] + try: + position = position_stack.pop()[0] + except IndexError: + # TODO: Should this raise an error? + # Bottomed out of stack + pass qs = CableTermination.objects.filter( cable=terminations[0].cable, @@ -78,14 +83,14 @@ class AToManyCableProfile(BaseCableProfile): a_max_connections = 1 b_max_connections = None symmetrical = False - pop_stack_a_side = False + pop_stack_b_side = False class BToManyCableProfile(BaseCableProfile): a_max_connections = None b_max_connections = 1 symmetrical = False - pop_stack_b_side = False + pop_stack_a_side = False class Shuffle2x2MPOCableProfile(BaseCableProfile): diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index eb8994349..cb26bca6b 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -1,3 +1,4 @@ +from circuits.models import CircuitTermination from dcim.choices import CableProfileChoices from dcim.models import * from dcim.svg import CableTraceSVG @@ -10,6 +11,7 @@ class CablePathTests(CablePathTestCase): 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): @@ -154,20 +156,20 @@ class CablePathTests(CablePathTestCase): is_complete=True, is_active=True ) - # B-to-A paths are incomplete because A side has null position + # B-to-A paths all lead to Interface 1 path2 = self.assertPathExists( - (interfaces[1], cable1, []), - is_complete=False, + (interfaces[1], cable1, interfaces[0]), + is_complete=True, is_active=True ) path3 = self.assertPathExists( - (interfaces[2], cable1, []), - is_complete=False, + (interfaces[2], cable1, interfaces[0]), + is_complete=True, is_active=True ) path4 = self.assertPathExists( - (interfaces[3], cable1, []), - is_complete=False, + (interfaces[3], cable1, interfaces[0]), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -216,20 +218,20 @@ class CablePathTests(CablePathTestCase): cable1.clean() cable1.save() - # A-to-B paths are incomplete because A side has null position + # A-to-B paths all lead to Interface 4 path1 = self.assertPathExists( - (interfaces[0], cable1, []), - is_complete=False, + (interfaces[0], cable1, interfaces[3]), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - (interfaces[1], cable1, []), - is_complete=False, + (interfaces[1], cable1, interfaces[3]), + is_complete=True, is_active=True ) path3 = self.assertPathExists( - (interfaces[2], cable1, []), - is_complete=False, + (interfaces[2], cable1, interfaces[3]), + is_complete=True, is_active=True ) # B-to-A path leads to all interfaces @@ -369,3 +371,362 @@ class CablePathTests(CablePathTestCase): # 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.B_TO_MANY, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[frontport1], + ) + cable1.save() + cable2 = Cable( + profile=CableProfileChoices.A_TO_MANY, + a_terminations=[rearport1], + b_terminations=[interfaces[2], interfaces[3]] + ) + 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.B_TO_MANY, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[frontport1_1] + ) + cable1.save() + cable2 = Cable( + profile=CableProfileChoices.B_TO_MANY, + a_terminations=[interfaces[2], interfaces[3]], + b_terminations=[frontport1_2] + ) + cable2.save() + cable3 = Cable( + profile=CableProfileChoices.STRAIGHT_SINGLE, + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable3.save() + cable4 = Cable( + profile=CableProfileChoices.A_TO_MANY, + a_terminations=[frontport2_1], + b_terminations=[interfaces[4], interfaces[5]] + ) + cable4.save() + cable5 = Cable( + profile=CableProfileChoices.A_TO_MANY, + a_terminations=[frontport2_2], + b_terminations=[interfaces[6], interfaces[7]] + ) + 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.B_TO_MANY, + a_terminations=[interfaces[0], interfaces[1]], + b_terminations=[circuittermination1] + ) + cable1.save() + cable2 = Cable( + profile=CableProfileChoices.A_TO_MANY, + a_terminations=[circuittermination2], + b_terminations=[interfaces[2], interfaces[3]] + ) + 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.A_TO_MANY, + a_terminations=[interfaces[0]], + b_terminations=[front_ports[0], front_ports[1]] + ) + cable1.save() + cable2 = Cable( + a_terminations=[rear_ports[0], rear_ports[1]], + b_terminations=[rear_ports[2], rear_ports[3]] + ) + cable2.save() + cable3 = Cable( + profile=CableProfileChoices.B_TO_MANY, + a_terminations=[interfaces[1]], + b_terminations=[front_ports[2], front_ports[3]] + ) + 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() From fb2ea3744352375b37820e1b52d2bb45b4f999a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 16:01:14 -0500 Subject: [PATCH 07/51] Simplify A/B side popping logic --- netbox/dcim/cable_profiles.py | 12 ++++++------ netbox/dcim/choices.py | 14 -------------- netbox/dcim/models/cables.py | 5 +++-- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index b6fea1cc4..9aa68c821 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -12,9 +12,9 @@ class BaseCableProfile: # 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 + # Whether terminations on either side of the cable have a numeric position + a_side_numbered = True + b_side_numbered = True def clean(self, cable): if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections: @@ -52,7 +52,7 @@ class BaseCableProfile: 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): + if (local_end == 'A' and self.b_side_numbered) or (local_end == 'B' and self.a_side_numbered): try: position = position_stack.pop()[0] except IndexError: @@ -83,14 +83,14 @@ class AToManyCableProfile(BaseCableProfile): a_max_connections = 1 b_max_connections = None symmetrical = False - pop_stack_b_side = False + a_side_numbered = False class BToManyCableProfile(BaseCableProfile): a_max_connections = None b_max_connections = 1 symmetrical = False - pop_stack_a_side = False + b_side_numbered = False class Shuffle2x2MPOCableProfile(BaseCableProfile): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 3d8510eb3..b3e04808a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1733,20 +1733,6 @@ class CableProfileChoices(ChoiceSet): (SHUFFLE_2X2_MPO, _('Shuffle (2x2 MPO)')), ) - # TODO: Move these designations into the profiles - A_SIDE_NUMBERED = ( - STRAIGHT_SINGLE, - STRAIGHT_MULTI, - B_TO_MANY, - SHUFFLE_2X2_MPO, - ) - B_SIDE_NUMBERED = ( - STRAIGHT_SINGLE, - STRAIGHT_MULTI, - A_TO_MANY, - SHUFFLE_2X2_MPO, - ) - class CableTypeChoices(ChoiceSet): # Copper - Twisted Pair (UTP/STP) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 9bfdf0061..3aa916c45 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -327,6 +327,7 @@ class Cable(PrimaryModel): Create/delete CableTerminations for this Cable to reflect its current state. """ a_terminations, b_terminations = self.get_terminations() + profile = self.profile_class if self.profile else None # Delete any stale CableTerminations for termination, ct in a_terminations.items(): @@ -339,11 +340,11 @@ class Cable(PrimaryModel): # Save any new CableTerminations for i, termination in enumerate(self.a_terminations, start=1): if not termination.pk or termination not in a_terminations: - position = i if self.profile in CableProfileChoices.A_SIDE_NUMBERED else None + position = i if profile and profile.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: - position = i if self.profile in CableProfileChoices.B_SIDE_NUMBERED else None + position = i if profile and profile.b_side_numbered else None CableTermination(cable=self, cable_end='B', position=position, termination=termination).save() From 24c6653356020ebd7bbe71a93189ae540d600655 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 16:33:35 -0500 Subject: [PATCH 08/51] Add profile for 4x4 MPO8 shuffle cable --- netbox/dcim/cable_profiles.py | 36 +++++++- netbox/dcim/choices.py | 6 +- netbox/dcim/models/cables.py | 3 +- netbox/dcim/tests/test_cablepaths2.py | 120 +++++++++++++++++++++++++- 4 files changed, 155 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 9aa68c821..7a7f4953c 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -44,7 +44,12 @@ class BaseCableProfile: ) }) - def get_mapped_position(self, position): + 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): @@ -65,7 +70,7 @@ class BaseCableProfile: cable_end=terminations[0].opposite_cable_end ) if position is not None: - qs = qs.filter(position=self.get_mapped_position(position)) + qs = qs.filter(position=self.get_mapped_position(local_end, position)) return qs @@ -93,11 +98,11 @@ class BToManyCableProfile(BaseCableProfile): b_side_numbered = False -class Shuffle2x2MPOCableProfile(BaseCableProfile): +class Shuffle2x2MPO8CableProfile(BaseCableProfile): a_max_connections = 8 b_max_connections = 8 - def get_mapped_position(self, position): + def get_mapped_position(self, side, position): return { 1: 1, 2: 2, @@ -108,3 +113,26 @@ class Shuffle2x2MPOCableProfile(BaseCableProfile): 7: 7, 8: 8, }.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 + _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) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index b3e04808a..e1c090093 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1722,7 +1722,8 @@ class CableProfileChoices(ChoiceSet): STRAIGHT_MULTI = 'straight-multi' A_TO_MANY = 'a-to-many' B_TO_MANY = 'b-to-many' - SHUFFLE_2X2_MPO = 'shuffle-2x2-mpo' + SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8' + SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8' CHOICES = ( (STRAIGHT_SINGLE, _('Straight (single position)')), @@ -1730,7 +1731,8 @@ class CableProfileChoices(ChoiceSet): # TODO: Better names for many-to-one profiles? (A_TO_MANY, _('A to many')), (B_TO_MANY, _('B to many')), - (SHUFFLE_2X2_MPO, _('Shuffle (2x2 MPO)')), + (SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')), + (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')), ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 3aa916c45..d7777870e 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -138,7 +138,8 @@ class Cable(PrimaryModel): CableProfileChoices.STRAIGHT_MULTI: cable_profiles.StraightMultiCableProfile, CableProfileChoices.A_TO_MANY: cable_profiles.AToManyCableProfile, CableProfileChoices.B_TO_MANY: cable_profiles.BToManyCableProfile, - CableProfileChoices.SHUFFLE_2X2_MPO: cable_profiles.Shuffle2x2MPOCableProfile, + CableProfileChoices.SHUFFLE_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile, + CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile, }.get(self.profile) def _get_x_terminations(self, side): diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index cb26bca6b..54da8c1ad 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -262,7 +262,7 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_105_cable_profile_2x2_mpo(self): + def test_105_cable_profile_2x2_mpo8(self): """ [IF1:1] --C1-- [IF3:1] [IF1:2] [IF3:2] @@ -273,9 +273,10 @@ class CablePathTests(CablePathTestCase): [IF2:3] [IF4:3] [IF2:4] [IF4:4] - Cable profile: Shuffle (2x2 MPO) + 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'), @@ -284,6 +285,7 @@ class CablePathTests(CablePathTestCase): 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'), @@ -296,7 +298,7 @@ class CablePathTests(CablePathTestCase): # Create cable 1 cable1 = Cable( - profile=CableProfileChoices.SHUFFLE_2X2_MPO, + profile=CableProfileChoices.SHUFFLE_2X2_MPO8, a_terminations=interfaces[0:8], b_terminations=interfaces[8:16], ) @@ -372,6 +374,118 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) + def test_106_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] From a75dee745e93731392586d058faaa845048936b4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Nov 2025 16:41:13 -0500 Subject: [PATCH 09/51] Enable drag-and-drop of items within multiselect fields --- netbox/project-static/dist/netbox.js | Bin 383019 -> 383064 bytes netbox/project-static/dist/netbox.js.map | Bin 1774920 -> 1775113 bytes netbox/project-static/src/select/config.ts | 5 +++++ 3 files changed, 5 insertions(+) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 095469011d2bef21a3decdf5e887cdd258b4bd2a..54218a290694077c274dc8e10c30081d787549b9 100644 GIT binary patch delta 51 zcmZ3zLHx!B@rD-07N#xCTO6iGbu&p#?{{EkrFA(zqF+UIs z0I?tt3jr}mod^(%Zl8EZ?3;l#OtD|F}PNJh@qE4uzW2la&qjQ$CGei_95UJzr y=;*BD?&zN31Y|n<=ma`C20Mb75g@`%$Jx28eUYFz5K92DBoIq&UnD5Kj2Qp}R#)Qy delta 137 zcmeBNlXGHKPD2Z03sVbo3rh=Y3tJ0&3r7p*7Ot*4lh3|nZ&$m^1;pGy%mc){K+Ffk z{6H)K#DYL91jNEXECR%$+tu!heKV1E_ttTCboS8+baV`M1TiB(gqx1Db547*kT?)a Q0I?(xOKndUl3vCP06dK_)Bpeg diff --git a/netbox/project-static/src/select/config.ts b/netbox/project-static/src/select/config.ts index 0b0d6f294..0c25df2f4 100644 --- a/netbox/project-static/src/select/config.ts +++ b/netbox/project-static/src/select/config.ts @@ -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, }; From aa7eedac4247a90a13a9201f576a5dfa7e5e9744 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Nov 2025 12:14:06 -0500 Subject: [PATCH 10/51] Remove many-to-one profiles --- netbox/dcim/cable_profiles.py | 45 ++---- netbox/dcim/choices.py | 5 - netbox/dcim/models/cables.py | 9 +- netbox/dcim/tests/test_cablepaths.py | 49 ++++++ netbox/dcim/tests/test_cablepaths2.py | 224 +++++++++----------------- 5 files changed, 140 insertions(+), 192 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 7a7f4953c..818c90fa8 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -12,10 +12,6 @@ class BaseCableProfile: # Number of A & B terminations must match symmetrical = True - # Whether terminations on either side of the cable have a numeric position - a_side_numbered = True - b_side_numbered = True - def clean(self, cable): if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections: raise ValidationError({ @@ -54,24 +50,21 @@ class BaseCableProfile: 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.b_side_numbered) or (local_end == 'B' and self.a_side_numbered): - try: - position = position_stack.pop()[0] - except IndexError: - # TODO: Should this raise an error? - # Bottomed out of stack - pass - 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(local_end, position)) - return qs + + # 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): @@ -84,20 +77,6 @@ class StraightMultiCableProfile(BaseCableProfile): b_max_connections = None -class AToManyCableProfile(BaseCableProfile): - a_max_connections = 1 - b_max_connections = None - symmetrical = False - a_side_numbered = False - - -class BToManyCableProfile(BaseCableProfile): - a_max_connections = None - b_max_connections = 1 - symmetrical = False - b_side_numbered = False - - class Shuffle2x2MPO8CableProfile(BaseCableProfile): a_max_connections = 8 b_max_connections = 8 @@ -129,7 +108,7 @@ class Shuffle4x4MPO8CableProfile(BaseCableProfile): 7: 6, 8: 8, } - # B side to A side position mapping + # 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): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index e1c090093..0656d96aa 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1720,17 +1720,12 @@ class PortTypeChoices(ChoiceSet): class CableProfileChoices(ChoiceSet): STRAIGHT_SINGLE = 'straight-single' STRAIGHT_MULTI = 'straight-multi' - A_TO_MANY = 'a-to-many' - B_TO_MANY = 'b-to-many' SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8' SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8' 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_2X2_MPO8, _('Shuffle (2x2 MPO8)')), (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')), ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index d7777870e..94ebc7570 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -21,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', @@ -136,8 +136,6 @@ class Cable(PrimaryModel): 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_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile, CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile, }.get(self.profile) @@ -328,7 +326,6 @@ class Cable(PrimaryModel): Create/delete CableTerminations for this Cable to reflect its current state. """ a_terminations, b_terminations = self.get_terminations() - profile = self.profile_class if self.profile else None # Delete any stale CableTerminations for termination, ct in a_terminations.items(): @@ -341,11 +338,11 @@ class Cable(PrimaryModel): # Save any new CableTerminations for i, termination in enumerate(self.a_terminations, start=1): if not termination.pk or termination not in a_terminations: - position = i if profile and profile.a_side_numbered else None + 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: - position = i if profile and profile.b_side_numbered else None + position = i if self.profile and isinstance(termination, PathEndpoint) else None CableTermination(cable=self, cable_end='B', position=position, termination=termination).save() diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 874b68340..d3a7cfc5e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2191,6 +2191,55 @@ class LegacyCablePathTests(CablePathTestCase): 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] diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 54da8c1ad..8e9a4961f 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -1,3 +1,5 @@ +from unittest import skipIf + from circuits.models import CircuitTermination from dcim.choices import CableProfileChoices from dcim.models import * @@ -126,143 +128,7 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_103_cable_profile_a_to_many(self): - """ - [IF1] --C1-- [IF2] - [IF3] - [IF4] - - Cable profile: A to many - """ - 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.A_TO_MANY, - a_terminations=[interfaces[0]], - b_terminations=[interfaces[1], interfaces[2], interfaces[3]], - ) - cable1.clean() - cable1.save() - - # A-to-B path leads to all interfaces - path1 = self.assertPathExists( - (interfaces[0], cable1, [interfaces[1], interfaces[2], interfaces[3]]), - is_complete=True, - is_active=True - ) - # B-to-A paths all lead to Interface 1 - path2 = self.assertPathExists( - (interfaces[1], cable1, interfaces[0]), - 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[0]), - 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.assertIsNone(interfaces[0].cable_position) - self.assertEqual(interfaces[1].cable_position, 1) - self.assertEqual(interfaces[2].cable_position, 2) - self.assertEqual(interfaces[3].cable_position, 3) - - # 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_b_to_many(self): - """ - [IF1] --C1-- [IF4] - [IF2] - [IF3] - - Cable profile: B to many - """ - 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.B_TO_MANY, - a_terminations=[interfaces[0], interfaces[1], interfaces[2]], - b_terminations=[interfaces[3]], - ) - cable1.clean() - cable1.save() - - # A-to-B paths all lead to Interface 4 - path1 = self.assertPathExists( - (interfaces[0], cable1, interfaces[3]), - 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[3]), - is_complete=True, - is_active=True - ) - # B-to-A path leads to all interfaces - path4 = self.assertPathExists( - (interfaces[3], cable1, [interfaces[0], interfaces[1], interfaces[2]]), - 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, 3) - self.assertIsNone(interfaces[3].cable_position) - - # 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_105_cable_profile_2x2_mpo8(self): + def test_103_cable_profile_2x2_mpo8(self): """ [IF1:1] --C1-- [IF3:1] [IF1:2] [IF3:2] @@ -374,7 +240,7 @@ class CablePathTests(CablePathTestCase): # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) - def test_106_cable_profile_4x4_mpo8(self): + def test_104_cable_profile_4x4_mpo8(self): """ [IF1:1] --C1-- [IF3:1] [IF1:2] [IF3:2] @@ -507,13 +373,13 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.B_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[interfaces[0], interfaces[1]], b_terminations=[frontport1], ) cable1.save() cable2 = Cable( - profile=CableProfileChoices.A_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[rearport1], b_terminations=[interfaces[2], interfaces[3]] ) @@ -586,13 +452,13 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.B_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[interfaces[0], interfaces[1]], b_terminations=[frontport1_1] ) cable1.save() cable2 = Cable( - profile=CableProfileChoices.B_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[interfaces[2], interfaces[3]], b_terminations=[frontport1_2] ) @@ -604,13 +470,13 @@ class CablePathTests(CablePathTestCase): ) cable3.save() cable4 = Cable( - profile=CableProfileChoices.A_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[frontport2_1], b_terminations=[interfaces[4], interfaces[5]] ) cable4.save() cable5 = Cable( - profile=CableProfileChoices.A_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[frontport2_2], b_terminations=[interfaces[6], interfaces[7]] ) @@ -722,13 +588,13 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.B_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[interfaces[0], interfaces[1]], b_terminations=[circuittermination1] ) cable1.save() cable2 = Cable( - profile=CableProfileChoices.A_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[circuittermination2], b_terminations=[interfaces[2], interfaces[3]] ) @@ -801,7 +667,7 @@ class CablePathTests(CablePathTestCase): # Create cables cable1 = Cable( - profile=CableProfileChoices.A_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[interfaces[0]], b_terminations=[front_ports[0], front_ports[1]] ) @@ -812,7 +678,7 @@ class CablePathTests(CablePathTestCase): ) cable2.save() cable3 = Cable( - profile=CableProfileChoices.B_TO_MANY, + profile=CableProfileChoices.STRAIGHT_MULTI, a_terminations=[interfaces[1]], b_terminations=[front_ports[2], front_ports[3]] ) @@ -844,3 +710,65 @@ class CablePathTests(CablePathTestCase): # 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.save() + cable2 = Cable( + profile=CableProfileChoices.STRAIGHT_MULTI, + a_terminations=[rearport1, rearport2], + b_terminations=[interfaces[2], interfaces[3]] + ) + 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) From 576c0db77d3ad9251625b706cd83ffcfa4726a06 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Nov 2025 12:24:02 -0500 Subject: [PATCH 11/51] Document profile field --- docs/models/dcim/cable.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md index 20f6c03c7..8a60d8353 100644 --- a/docs/models/dcim/cable.md +++ b/docs/models/dcim/cable.md @@ -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. From 5a7f86a6f583ba5d4fdafce568ad44d5948ca755 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Nov 2025 12:38:51 -0500 Subject: [PATCH 12/51] Clean up cable profiles --- netbox/dcim/cable_profiles.py | 33 ++++++++++----------------- netbox/dcim/tests/test_cablepaths2.py | 14 ++++++++++++ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/netbox/dcim/cable_profiles.py b/netbox/dcim/cable_profiles.py index 818c90fa8..4251cd4d9 100644 --- a/netbox/dcim/cable_profiles.py +++ b/netbox/dcim/cable_profiles.py @@ -9,10 +9,8 @@ class BaseCableProfile: a_max_connections = None b_max_connections = None - # Number of A & B terminations must match - symmetrical = True - 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': _( @@ -31,14 +29,6 @@ class BaseCableProfile: max=self.b_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, side, position): """ @@ -80,18 +70,19 @@ class StraightMultiCableProfile(BaseCableProfile): 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 { - 1: 1, - 2: 2, - 3: 5, - 4: 6, - 5: 3, - 6: 4, - 7: 7, - 8: 8, - }.get(position) + return self._mapping.get(position) class Shuffle4x4MPO8CableProfile(BaseCableProfile): diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 8e9a4961f..c7895c6d2 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -377,12 +377,14 @@ class CablePathTests(CablePathTestCase): 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 = [ @@ -456,30 +458,35 @@ class CablePathTests(CablePathTestCase): 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 = [ @@ -592,12 +599,14 @@ class CablePathTests(CablePathTestCase): 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 @@ -671,17 +680,20 @@ class CablePathTests(CablePathTestCase): 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 @@ -739,12 +751,14 @@ class CablePathTests(CablePathTestCase): 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(): From b235c5c99f4be8fc88225c19953d44a7a444638a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Nov 2025 12:42:40 -0500 Subject: [PATCH 13/51] Rebase migrations --- .../{0218_cable_positions.py => 0219_cable_profile.py} | 2 +- .../{0219_cable_position.py => 0220_cable_position.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename netbox/dcim/migrations/{0218_cable_positions.py => 0219_cable_profile.py} (95%) rename netbox/dcim/migrations/{0219_cable_position.py => 0220_cable_position.py} (98%) diff --git a/netbox/dcim/migrations/0218_cable_positions.py b/netbox/dcim/migrations/0219_cable_profile.py similarity index 95% rename from netbox/dcim/migrations/0218_cable_positions.py rename to netbox/dcim/migrations/0219_cable_profile.py index 662a7d014..140ce800b 100644 --- a/netbox/dcim/migrations/0218_cable_positions.py +++ b/netbox/dcim/migrations/0219_cable_profile.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0217_owner'), + ('dcim', '0218_devicetype_device_count'), ] operations = [ diff --git a/netbox/dcim/migrations/0219_cable_position.py b/netbox/dcim/migrations/0220_cable_position.py similarity index 98% rename from netbox/dcim/migrations/0219_cable_position.py rename to netbox/dcim/migrations/0220_cable_position.py index 7b67eebd7..6ee74174b 100644 --- a/netbox/dcim/migrations/0219_cable_position.py +++ b/netbox/dcim/migrations/0220_cable_position.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0218_cable_positions'), + ('dcim', '0219_cable_profile'), ] operations = [ From 2b420bde3a5841d9925ae32de981e4b26adde42f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Nov 2025 14:22:16 -0500 Subject: [PATCH 14/51] Introduce PortAssignment M2M mapping --- netbox/dcim/constants.py | 4 +- .../migrations/0221_m2m_port_assignments.py | 85 +++++++++++++++++++ .../dcim/models/device_component_templates.py | 8 +- netbox/dcim/models/device_components.py | 53 +++++++++++- 4 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 netbox/dcim/migrations/0221_m2m_port_assignments.py diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 5e4311c13..16926081f 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -32,8 +32,8 @@ CABLE_POSITION_MAX = 1024 # RearPorts # -REARPORT_POSITIONS_MIN = 1 -REARPORT_POSITIONS_MAX = 1024 +PORT_POSITION_MIN = 1 +PORT_POSITION_MAX = 1024 # diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py new file mode 100644 index 000000000..d6c3710d4 --- /dev/null +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -0,0 +1,85 @@ +import django.core.validators +import django.db.models.deletion +from django.db import migrations +from django.db import models +from itertools import islice + + +def chunked(iterable, size): + """Yield successive chunks of a given size from an iterator.""" + iterator = iter(iterable) + while chunk := list(islice(iterator, size)): + yield chunk + + +def populate_port_assignments(apps, schema_editor): + FrontPort = apps.get_model('dcim', 'FrontPort') + PortAssignment = apps.get_model('dcim', 'PortAssignment') + + front_ports = FrontPort.objects.iterator(chunk_size=1000) + + def generate_copies(): + for front_port in front_ports: + yield PortAssignment( + front_port_id=front_port.pk, + front_port_position=1, + rear_port_id=front_port.rear_port_id, + rear_port_position=front_port.rear_port_position, + ) + + # Bulk insert in streaming batches + for chunk in chunked(generate_copies(), 1000): + PortAssignment.objects.bulk_create(chunk, batch_size=1000) + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0220_cable_position'), + ] + + operations = [ + migrations.CreateModel( + name='PortAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ( + 'front_port_position', + models.PositiveSmallIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ] + ), + ), + ( + 'rear_port_position', + models.PositiveSmallIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ] + ), + ), + ('front_port', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.frontport')), + ('rear_port', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.rearport')), + ], + ), + migrations.AddField( + model_name='frontport', + name='rear_ports', + field=models.ManyToManyField(related_name='front_ports', through='dcim.PortAssignment', to='dcim.rearport'), + ), + migrations.AddConstraint( + model_name='portassignment', + constraint=models.UniqueConstraint( + fields=('front_port', 'front_port_position'), name='dcim_portassignment_unique_front_port_position' + ), + ), + migrations.AddConstraint( + model_name='portassignment', + constraint=models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), name='dcim_portassignment_unique_rear_port_position' + ), + ), + migrations.RunPython(code=populate_port_assignments, reverse_code=migrations.RunPython.noop), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 74e624d6c..177da1765 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -540,8 +540,8 @@ class FrontPortTemplate(ModularComponentTemplateModel): verbose_name=_('rear port position'), default=1, validators=[ - MinValueValidator(REARPORT_POSITIONS_MIN), - MaxValueValidator(REARPORT_POSITIONS_MAX) + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX) ] ) @@ -635,8 +635,8 @@ class RearPortTemplate(ModularComponentTemplateModel): verbose_name=_('positions'), default=1, validators=[ - MinValueValidator(REARPORT_POSITIONS_MIN), - MaxValueValidator(REARPORT_POSITIONS_MAX) + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX) ] ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 8c9acc48f..3f90963a7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1069,6 +1069,44 @@ class Interface( # Pass-through ports # +class PortAssignment(models.Model): + """ + Maps a FrontPort & position to a RearPort & position. + """ + front_port = models.ForeignKey( + to='dcim.FrontPort', + on_delete=models.CASCADE, + ) + front_port_position = models.PositiveSmallIntegerField( + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ) + ) + rear_port = models.ForeignKey( + to='dcim.RearPort', + on_delete=models.CASCADE, + ) + rear_port_position = models.PositiveSmallIntegerField( + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ) + ) + + class Meta: + constraints = ( + models.UniqueConstraint( + fields=('front_port', 'front_port_position'), + name='%(app_label)s_%(class)s_unique_front_port_position' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), + ) + + class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): """ A pass-through port on the front of a Device. @@ -1082,6 +1120,13 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): verbose_name=_('color'), blank=True ) + rear_ports = models.ManyToManyField( + to='dcim.RearPort', + through='dcim.PortAssignment', + related_name='front_ports', + ) + + # Legacy fields rear_port = models.ForeignKey( to='dcim.RearPort', on_delete=models.CASCADE, @@ -1091,8 +1136,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): verbose_name=_('rear port position'), default=1, validators=[ - MinValueValidator(REARPORT_POSITIONS_MIN), - MaxValueValidator(REARPORT_POSITIONS_MAX) + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX) ], help_text=_('Mapped position on corresponding rear port') ) @@ -1157,8 +1202,8 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): verbose_name=_('positions'), default=1, validators=[ - MinValueValidator(REARPORT_POSITIONS_MIN), - MaxValueValidator(REARPORT_POSITIONS_MAX) + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX) ], help_text=_('Number of front ports which may be mapped') ) From c09b0771b262765878322863e7d3241c2c0920af Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Nov 2025 14:46:55 -0500 Subject: [PATCH 15/51] Add positions field on FrontPort; remove legacy fields --- .../api/serializers_/device_components.py | 6 ++-- netbox/dcim/filtersets.py | 13 +++++--- netbox/dcim/forms/bulk_import.py | 3 +- netbox/dcim/forms/model_forms.py | 4 +-- netbox/dcim/forms/object_create.py | 2 +- netbox/dcim/graphql/filters.py | 6 +--- .../migrations/0222_frontport_positions.py | 31 +++++++++++++++++++ netbox/dcim/models/device_components.py | 23 +++++--------- netbox/dcim/signals.py | 6 ++-- netbox/dcim/tables/devices.py | 6 ++-- netbox/templates/dcim/frontport.html | 8 ++--- 11 files changed, 63 insertions(+), 45 deletions(-) create mode 100644 netbox/dcim/migrations/0222_frontport_positions.py diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index b26cf9bbb..ecd8207e3 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -338,9 +338,9 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): class Meta: model = FrontPort fields = [ - 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', - 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', - 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', + 'custom_fields', 'created', 'last_updated', '_occupied', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 9c161aa54..52ee68929 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -2101,14 +2101,15 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) choices=PortTypeChoices, null_value=None ) - rear_port_id = django_filters.ModelMultipleChoiceFilter( - queryset=RearPort.objects.all() - ) + # TODO + # rear_port_id = django_filters.ModelMultipleChoiceFilter( + # queryset=RearPort.objects.all() + # ) class Meta: model = FrontPort fields = ( - 'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end', + 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', 'cable_position', ) @@ -2118,6 +2119,10 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): choices=PortTypeChoices, null_value=None ) + # TODO + # front_port_id = django_filters.ModelMultipleChoiceFilter( + # queryset=FrontPort.objects.all() + # ) class Meta: model = RearPort diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index ba0b44b0d..eb41af0eb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1090,8 +1090,7 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm): class Meta: model = FrontPort fields = ( - 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position', - 'description', 'owner', 'tags' + 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 75a827476..af0662d1e 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1595,8 +1595,8 @@ class FrontPortForm(ModularDeviceComponentForm): class Meta: model = FrontPort fields = [ - 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', - 'description', 'owner', 'tags', + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner', + 'tags', ] diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index d9afabddf..aa14ed9cc 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -284,7 +284,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): ) class Meta(model_forms.FrontPortForm.Meta): - exclude = ('name', 'label', 'rear_port', 'rear_port_position') + exclude = ('name', 'label', 'positions') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 111902dd9..398c065a6 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -395,11 +395,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( - strawberry_django.filter_field() - ) - rear_port_id: ID | None = strawberry_django.filter_field() - rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/dcim/migrations/0222_frontport_positions.py b/netbox/dcim/migrations/0222_frontport_positions.py new file mode 100644 index 000000000..2ef7ff088 --- /dev/null +++ b/netbox/dcim/migrations/0222_frontport_positions.py @@ -0,0 +1,31 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0221_m2m_port_assignments'), + ] + + operations = [ + migrations.RemoveField( + model_name='frontport', + name='rear_port', + ), + migrations.RemoveField( + model_name='frontport', + name='rear_port_position', + ), + migrations.AddField( + model_name='frontport', + name='positions', + field=models.PositiveSmallIntegerField( + default=1, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024) + ] + ), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3f90963a7..f906add7c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1120,26 +1120,18 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): verbose_name=_('color'), blank=True ) - rear_ports = models.ManyToManyField( - to='dcim.RearPort', - through='dcim.PortAssignment', - related_name='front_ports', - ) - - # Legacy fields - rear_port = models.ForeignKey( - to='dcim.RearPort', - on_delete=models.CASCADE, - related_name='frontports' - ) - rear_port_position = models.PositiveSmallIntegerField( - verbose_name=_('rear port position'), + positions = models.PositiveSmallIntegerField( + verbose_name=_('positions'), default=1, validators=[ MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) ], - help_text=_('Mapped position on corresponding rear port') + ) + rear_ports = models.ManyToManyField( + to='dcim.RearPort', + through='dcim.PortAssignment', + related_name='front_ports', ) clone_fields = ('device', 'type', 'color') @@ -1205,7 +1197,6 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) ], - help_text=_('Number of front ports which may be mapped') ) clone_fields = ('device', 'type', 'color', 'positions') diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index eb1825c1a..636d7f484 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -156,9 +156,9 @@ def extend_rearport_cable_paths(instance, created, raw, **kwargs): When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort. """ if created and not raw: - rearport = instance.rear_port - for cablepath in CablePath.objects.filter(_nodes__contains=rearport): - cablepath.retrace() + for rear_port in instance.rear_ports.all(): + for cablepath in CablePath.objects.filter(_nodes__contains=rear_port): + cablepath.retrace() @receiver(post_save, sender=Interface) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 5f9467297..cfdeb14ce 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -763,12 +763,12 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = models.FrontPort fields = ( - 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', - 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', + 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', ) diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 9f4d23e60..dc68fbfa9 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -47,12 +47,8 @@ - {% trans "Rear Port" %} - {{ object.rear_port|linkify }} - - - {% trans "Rear Port Position" %} - {{ object.rear_port_position }} + {% trans "Positions" %} + {{ object.positions }} {% trans "Description" %} From 6a7027aebb5cccc0f93d0358ef480beef829abd7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Nov 2025 10:41:47 -0500 Subject: [PATCH 16/51] Update FrontPort model form --- .../api/serializers_/device_components.py | 6 +- netbox/dcim/forms/model_forms.py | 64 +++++++++++++++++-- .../migrations/0222_frontport_positions.py | 4 ++ netbox/dcim/models/device_components.py | 54 +++++++--------- netbox/dcim/views.py | 11 ++++ netbox/templates/dcim/frontport.html | 12 ++++ netbox/templates/dcim/rearport.html | 12 ++++ 7 files changed, 124 insertions(+), 39 deletions(-) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index ecd8207e3..22fe4777c 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -333,14 +333,14 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): allow_null=True ) type = ChoiceField(choices=PortTypeChoices) - rear_port = FrontPortRearPortSerializer() + rear_ports = FrontPortRearPortSerializer(many=True) class Meta: model = FrontPort fields = [ 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', - 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', - 'custom_fields', 'created', 'last_updated', '_occupied', + 'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index af0662d1e..028315211 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1578,16 +1578,15 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): class FrontPortForm(ModularDeviceComponentForm): - rear_port = DynamicModelChoiceField( - queryset=RearPort.objects.all(), - query_params={ - 'device_id': '$device', - } + rear_ports = forms.MultipleChoiceField( + choices=[], + label=_('Rear ports'), + widget=forms.SelectMultiple(attrs={'size': 6}) ) fieldsets = ( FieldSet( - 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected', 'description', 'tags', ), ) @@ -1599,6 +1598,59 @@ class FrontPortForm(ModularDeviceComponentForm): 'tags', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if device_id := (self.data.get('device') or self.initial.get('device')): + device = Device.objects.get(pk=device_id) + else: + return + + # Populate rear port choices + choices = [] + for rear_port in RearPort.objects.filter(device=device): + for i in range(1, rear_port.positions + 1): + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_ports'].choices = choices + + # Set initial rear port assignments + if self.instance.pk: + self.initial['rear_ports'] = [ + f'{assignment.rear_port_id}:{assignment.rear_port_position}' + for assignment in PortAssignment.objects.filter(front_port_id=self.instance.pk) + ] + + def clean(self): + + # Count of selected rear port & position pairs much match the assigned number of positions + if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']: + raise forms.ValidationError( + _("The number of rear port/position pairs selected must match the number of positions assigned.") + ) + + def _save_m2m(self): + super()._save_m2m() + + # TODO: Can this be made more efficient? + # Delete existing rear port assignments + PortAssignment.objects.filter(front_port_id=self.instance.pk).delete() + + # Create new rear port assignments + assignments = [] + for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1): + rear_port_id, rear_port_position = rp_position.split(':') + assignments.append( + PortAssignment( + front_port_id=self.instance.pk, + front_port_position=i, + rear_port_id=rear_port_id, + rear_port_position=rear_port_position, + ) + ) + PortAssignment.objects.bulk_create(assignments) + class RearPortForm(ModularDeviceComponentForm): fieldsets = ( diff --git a/netbox/dcim/migrations/0222_frontport_positions.py b/netbox/dcim/migrations/0222_frontport_positions.py index 2ef7ff088..93421ee47 100644 --- a/netbox/dcim/migrations/0222_frontport_positions.py +++ b/netbox/dcim/migrations/0222_frontport_positions.py @@ -9,6 +9,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RemoveConstraint( + model_name='frontport', + name='dcim_frontport_unique_rear_port_position', + ), migrations.RemoveField( model_name='frontport', name='rear_port', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index f906add7c..3c054891b 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -35,6 +35,7 @@ __all__ = ( 'InventoryItemRole', 'ModuleBay', 'PathEndpoint', + 'PortAssignment', 'PowerOutlet', 'PowerPort', 'RearPort', @@ -1106,6 +1107,29 @@ class PortAssignment(models.Model): ), ) + def clean(self): + + # Validate rear port assignment + if self.front_port.device_id != self.rear_port.device_id: + raise ValidationError({ + "rear_port": _("Rear port ({rear_port}) must belong to the same device").format( + rear_port=self.rear_port + ) + }) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError({ + "rear_port_position": _( + "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} " + "positions." + ).format( + rear_port_position=self.rear_port_position, + name=self.rear_port.name, + positions=self.rear_port.positions + ) + }) + class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): """ @@ -1142,40 +1166,10 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): fields=('device', 'name'), name='%(app_label)s_%(class)s_unique_device_name' ), - models.UniqueConstraint( - fields=('rear_port', 'rear_port_position'), - name='%(app_label)s_%(class)s_unique_rear_port_position' - ), ) verbose_name = _('front port') verbose_name_plural = _('front ports') - def clean(self): - super().clean() - - if hasattr(self, 'rear_port'): - - # Validate rear port assignment - if self.rear_port.device != self.device: - raise ValidationError({ - "rear_port": _( - "Rear port ({rear_port}) must belong to the same device" - ).format(rear_port=self.rear_port) - }) - - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError({ - "rear_port_position": _( - "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} " - "positions." - ).format( - rear_port_position=self.rear_port_position, - name=self.rear_port.name, - positions=self.rear_port.positions - ) - }) - class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): """ diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 463d98179..a3d0b2ded 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -42,6 +42,7 @@ from wireless.models import WirelessLAN from . import filtersets, forms, tables from .choices import DeviceFaceChoices, InterfaceModeChoices from .models import * +from .models.device_components import PortAssignment from .object_actions import BulkAddComponents, BulkDisconnect CABLE_TERMINATION_TYPES = { @@ -3242,6 +3243,11 @@ class FrontPortListView(generic.ObjectListView): class FrontPortView(generic.ObjectView): queryset = FrontPort.objects.all() + def get_extra_context(self, request, instance): + return { + 'rear_port_assignments': PortAssignment.objects.filter(front_port=instance).prefetch_related('rear_port'), + } + @register_model_view(FrontPort, 'add', detail=False) class FrontPortCreateView(generic.ComponentCreateView): @@ -3313,6 +3319,11 @@ class RearPortListView(generic.ObjectListView): class RearPortView(generic.ObjectView): queryset = RearPort.objects.all() + def get_extra_context(self, request, instance): + return { + 'front_port_assignments': PortAssignment.objects.filter(rear_port=instance).prefetch_related('front_port'), + } + @register_model_view(RearPort, 'add', detail=False) class RearPortCreateView(generic.ComponentCreateView): diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index dc68fbfa9..180c6c3e2 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -61,6 +61,18 @@ {% plugin_left_page object %}
+
+

{% trans "Rear Ports" %}

+ + {% for assignment in rear_port_assignments %} + + + + + + {% endfor %} +
{{ assignment.front_port_position }}{{ assignment.rear_port|linkify }}{{ assignment.rear_port_position }}
+

{% trans "Connection" %}

{% if object.mark_connected %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 3fed4307b..6b8236218 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -61,6 +61,18 @@ {% plugin_left_page object %}
+
+

{% trans "Rear Ports" %}

+ + {% for assignment in front_port_assignments %} + + + + + + {% endfor %} +
{{ assignment.rear_port_position }}{{ assignment.front_port|linkify }}{{ assignment.front_port_position }}
+

{% trans "Connection" %}

{% if object.mark_connected %} From 4790dbba96d096fa92b8251a37e4c8ed27388756 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Nov 2025 10:56:05 -0500 Subject: [PATCH 17/51] Exclude occupied rear port & position pairs from list of choices --- netbox/dcim/forms/model_forms.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 028315211..195f922ea 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1601,19 +1601,13 @@ class FrontPortForm(ModularDeviceComponentForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if device_id := (self.data.get('device') or self.initial.get('device')): + if device_id := self.data.get('device') or self.initial.get('device'): device = Device.objects.get(pk=device_id) else: return # Populate rear port choices - choices = [] - for rear_port in RearPort.objects.filter(device=device): - for i in range(1, rear_port.positions + 1): - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_ports'].choices = choices + self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance) # Set initial rear port assignments if self.instance.pk: @@ -1651,6 +1645,27 @@ class FrontPortForm(ModularDeviceComponentForm): ) PortAssignment.objects.bulk_create(assignments) + def _get_rear_port_choices(self, device, front_port): + """ + Return a list of choices representing each available rear port & position pair on the device, excluding those + assigned to the specified instance. + """ + occupied_rear_port_positions = [ + f'{assignment.rear_port_id}:{assignment.rear_port_position}' + for assignment in PortAssignment.objects.filter(front_port__device=device).exclude(front_port=front_port) + ] + + choices = [] + for rear_port in RearPort.objects.filter(device=device): + for i in range(1, rear_port.positions + 1): + pair_id = f'{rear_port.pk}:{i}' + if pair_id not in occupied_rear_port_positions: + pair_label = f'{rear_port.name}:{i}' + choices.append( + (pair_id, pair_label) + ) + return choices + class RearPortForm(ModularDeviceComponentForm): fieldsets = ( From a7c3971a43894f23617f9d48d6b9a0188e03a258 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Nov 2025 11:21:29 -0500 Subject: [PATCH 18/51] Fix FrontPortCreateForm --- netbox/dcim/forms/model_forms.py | 3 +- netbox/dcim/forms/object_create.py | 55 +++++---------------- netbox/netbox/views/generic/object_views.py | 6 +-- 3 files changed, 17 insertions(+), 47 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 195f922ea..67cd58ad1 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1617,6 +1617,7 @@ class FrontPortForm(ModularDeviceComponentForm): ] def clean(self): + super().clean() # Count of selected rear port & position pairs much match the assigned number of positions if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']: @@ -1652,7 +1653,7 @@ class FrontPortForm(ModularDeviceComponentForm): """ occupied_rear_port_positions = [ f'{assignment.rear_port_id}:{assignment.rear_port_position}' - for assignment in PortAssignment.objects.filter(front_port__device=device).exclude(front_port=front_port) + for assignment in PortAssignment.objects.filter(front_port__device=device).exclude(front_port=front_port.pk) ] choices = [] diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index aa14ed9cc..028ab6c3c 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -269,58 +269,30 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): } ) ) - rear_port = forms.MultipleChoiceField( - choices=[], - label=_('Rear ports'), - help_text=_('Select one rear port assignment for each front port being created.'), - widget=forms.SelectMultiple(attrs={'size': 6}) - ) # Override fieldsets from FrontPortForm to omit rear_port_position fieldsets = ( FieldSet( - 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected', + 'description', 'tags', ), ) - class Meta(model_forms.FrontPortForm.Meta): - exclude = ('name', 'label', 'positions') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if device_id := self.data.get('device') or self.initial.get('device'): - device = Device.objects.get(pk=device_id) - else: - return - - # Determine which rear port positions are occupied. These will be excluded from the list of available - # mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device.frontports.all() + class Meta: + model = FrontPort + fields = [ + 'device', 'module', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner', 'tags', ] - # Populate rear port choices - choices = [] - rear_ports = RearPort.objects.filter(device=device) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port'].choices = choices - def clean(self): - super().clean() + super(NetBoxModelForm, self).clean() # Check that the number of FrontPorts to be created matches the selected number of RearPort positions frontport_count = len(self.cleaned_data['name']) - rearport_count = len(self.cleaned_data['rear_port']) + rearport_count = len(self.cleaned_data['rear_ports']) if frontport_count != rearport_count: raise forms.ValidationError({ - 'rear_port': _( + 'rear_ports': _( "The number of front ports to be created ({frontport_count}) must match the selected number of " "rear port positions ({rearport_count})." ).format( @@ -330,13 +302,10 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): }) def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') - + positions = self.cleaned_data['positions'] + offset = positions * iteration return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), + 'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions] } diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 88a3456f7..818cea610 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -1,6 +1,5 @@ import logging from collections import defaultdict -from copy import deepcopy from django.contrib import messages from django.db import router, transaction @@ -563,7 +562,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): if form.is_valid(): new_components = [] - data = deepcopy(request.POST) + data = request.POST.copy() pattern_count = len(form.cleaned_data[self.form.replication_fields[0]]) for i in range(pattern_count): @@ -572,7 +571,8 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): data[field_name] = form.cleaned_data[field_name][i] if hasattr(form, 'get_iterative_data'): - data.update(form.get_iterative_data(i)) + for k, v in form.get_iterative_data(i).items(): + data.setlist(k, v) component_form = self.model_form(data) From 5e8d57f2314f22e4e040394a05cd7646f2b9e97c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Nov 2025 16:53:53 -0500 Subject: [PATCH 19/51] Update path tracing logic (WIP) --- netbox/dcim/models/cables.py | 119 ++++-- netbox/dcim/tests/test_cablepaths.py | 617 ++++++++++++++++----------- 2 files changed, 437 insertions(+), 299 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 94ebc7570..33d8d986b 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -21,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, PathEndpoint, RearPort +from .device_components import FrontPort, PathEndpoint, RearPort, PortAssignment __all__ = ( 'Cable', @@ -775,58 +775,83 @@ class CablePath(models.Model): if isinstance(remote_terminations[0], FrontPort): # Follow FrontPorts to their corresponding RearPorts - rear_ports = RearPort.objects.filter( - pk__in=[t.rear_port_id for t in remote_terminations] - ) - if len(rear_ports) > 1 or rear_ports[0].positions > 1: - position_stack.append([fp.rear_port_position for fp in remote_terminations]) - - terminations = rear_ports - - elif isinstance(remote_terminations[0], RearPort): - if len(remote_terminations) == 1 and remote_terminations[0].positions == 1: - front_ports = FrontPort.objects.filter( - rear_port_id__in=[rp.pk for rp in remote_terminations], - rear_port_position=1 - ) - # Obtain the individual front ports based on the termination and all positions - elif len(remote_terminations) > 1 and position_stack: + if position_stack: positions = position_stack.pop() - - # Ensure we have a number of positions equal to the amount of remote terminations - if len(remote_terminations) != len(positions): - raise UnsupportedCablePath( - _("All positions counts within the path on opposite ends of links must match") - ) - - # Get our front ports q_filter = Q() for rt in remote_terminations: position = positions.pop() - q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) - if q_filter is Q(): - raise UnsupportedCablePath(_("Remote termination position filter is missing")) - front_ports = FrontPort.objects.filter(q_filter) - # Obtain the individual front ports based on the termination and position - elif position_stack: - front_ports = FrontPort.objects.filter( - rear_port_id=remote_terminations[0].pk, - rear_port_position__in=position_stack.pop() - ) - # If all rear ports have a single position, we can just get the front ports - elif all([rp.positions == 1 for rp in remote_terminations]): - front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations]) - - if len(front_ports) != len(remote_terminations): - # Some rear ports does not have a front port - is_split = True - break + q_filter |= Q(front_port=rt, front_port_position=position) + port_assignments = PortAssignment.objects.filter(q_filter) else: - # No position indicated: path has split, so we stop at the RearPorts - is_split = True - break + port_assignments = PortAssignment.objects.filter(front_port__in=remote_terminations) - terminations = front_ports + if not port_assignments: + print('No front-to-rear port assignments found') + break + position_stack.append([assignment.rear_port_position for assignment in port_assignments]) + terminations = [assignment.rear_port for assignment in port_assignments] + + elif isinstance(remote_terminations[0], RearPort): + # Follow RearPorts to their corresponding FrontPorts + if position_stack: + positions = position_stack.pop() + q_filter = Q() + for rt in remote_terminations: + position = positions.pop() + q_filter |= Q(rear_port=rt, rear_port_position=position) + port_assignments = PortAssignment.objects.filter(q_filter) + else: + port_assignments = PortAssignment.objects.filter(rear_port__in=remote_terminations) + + if not port_assignments: + print('No rear-to-front port assignments found') + break + position_stack.append([assignment.front_port_position for assignment in port_assignments]) + terminations = [assignment.front_port for assignment in port_assignments] + + # if len(remote_terminations) == 1 and remote_terminations[0].positions == 1: + # front_ports = FrontPort.objects.filter( + # rear_port_id__in=[rp.pk for rp in remote_terminations], + # rear_port_position=1 + # ) + # # Obtain the individual front ports based on the termination and all positions + # elif len(remote_terminations) > 1 and position_stack: + # positions = position_stack.pop() + # + # # Ensure we have a number of positions equal to the amount of remote terminations + # if len(remote_terminations) != len(positions): + # raise UnsupportedCablePath( + # _("All positions counts within the path on opposite ends of links must match") + # ) + # + # # Get our front ports + # q_filter = Q() + # for rt in remote_terminations: + # position = positions.pop() + # q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) + # if q_filter is Q(): + # raise UnsupportedCablePath(_("Remote termination position filter is missing")) + # front_ports = FrontPort.objects.filter(q_filter) + # # Obtain the individual front ports based on the termination and position + # elif position_stack: + # front_ports = FrontPort.objects.filter( + # rear_port_id=remote_terminations[0].pk, + # rear_port_position__in=position_stack.pop() + # ) + # # If all rear ports have a single position, we can just get the front ports + # elif all([rp.positions == 1 for rp in remote_terminations]): + # front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations]) + # + # if len(front_ports) != len(remote_terminations): + # # Some rear ports does not have a front port + # is_split = True + # break + # else: + # # No position indicated: path has split, so we stop at the RearPorts + # is_split = True + # break + # + # terminations = front_ports elif isinstance(remote_terminations[0], CircuitTermination): # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index d3a7cfc5e..a36688e07 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -281,9 +281,13 @@ class LegacyCablePathTests(CablePathTestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - 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 + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + PortAssignment.objects.create( + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1 ) # Create cable 1 @@ -340,9 +344,13 @@ class LegacyCablePathTests(CablePathTestCase): 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) - frontport1 = FrontPort.objects.create( - device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + PortAssignment.objects.create( + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1 ) # Create cable 1 @@ -403,18 +411,24 @@ class LegacyCablePathTests(CablePathTestCase): interface4 = Interface.objects.create(device=self.device, name='Interface 4') 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 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') + frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + ), + ]) # Create cables 1-2 cable1 = Cable( @@ -521,18 +535,24 @@ class LegacyCablePathTests(CablePathTestCase): interface8 = 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 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') + frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + ), + ]) # Create cables 1-2 cable1 = Cable( @@ -680,27 +700,35 @@ class LegacyCablePathTests(CablePathTestCase): 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=4) - rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3') rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', 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 = FrontPort.objects.create( - device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 - ) - frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 - ) - frontport4_1 = FrontPort.objects.create( - device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1 - ) - frontport4_2 = FrontPort.objects.create( - device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') + frontport4_1 = FrontPort.objects.create(device=self.device, name='Front Port 4:1') + frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, + ), + ]) # Create cables 1-2, 6-7 cable1 = Cable( @@ -801,30 +829,40 @@ class LegacyCablePathTests(CablePathTestCase): rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4) rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', 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 - ) - frontport3_1 = FrontPort.objects.create( - device=self.device, name='Front Port 3:1', rear_port=rearport3, rear_port_position=1 - ) - frontport3_2 = FrontPort.objects.create( - device=self.device, name='Front Port 3:2', rear_port=rearport3, rear_port_position=2 - ) - frontport4_1 = FrontPort.objects.create( - device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1 - ) - frontport4_2 = FrontPort.objects.create( - device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') + frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + frontport3_1 = FrontPort.objects.create(device=self.device, name='Front Port 3:1') + frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2') + frontport4_1 = FrontPort.objects.create(device=self.device, name='Front Port 4:1') + frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + ), + PortAssignment( + front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, + ), + PortAssignment( + front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, + ), + ]) # Create cables 1-3, 6-8 cable1 = Cable( @@ -928,23 +966,30 @@ class LegacyCablePathTests(CablePathTestCase): 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=4) - rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5') rearport3 = 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 = FrontPort.objects.create( - device=self.device, name='Front Port 5', rear_port=rearport2, rear_port_position=1 - ) - frontport3_1 = FrontPort.objects.create( - device=self.device, name='Front Port 2:1', rear_port=rearport3, rear_port_position=1 - ) - frontport3_2 = FrontPort.objects.create( - device=self.device, name='Front Port 2:2', rear_port=rearport3, rear_port_position=2 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 5') + frontport3_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') + frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, + ), + ]) # Create cables 1-2, 5-6 cable1 = Cable( @@ -1033,12 +1078,16 @@ class LegacyCablePathTests(CablePathTestCase): interface2 = Interface.objects.create(device=self.device, name='Interface 2') interface3 = Interface.objects.create(device=self.device, name='Interface 3') rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', 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 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + ]) # Create cables 1 cable1 = Cable( @@ -1098,10 +1147,11 @@ class LegacyCablePathTests(CablePathTestCase): [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') - 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 + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + PortAssignment.objects.create( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ) # Create cables @@ -1413,18 +1463,24 @@ class LegacyCablePathTests(CablePathTestCase): interface4 = Interface.objects.create(device=self.device, name='Interface 4') 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 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') + frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + ), + ]) circuittermination1 = CircuitTermination.objects.create( circuit=self.circuit, termination=self.site, @@ -1601,22 +1657,28 @@ class LegacyCablePathTests(CablePathTestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - 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) - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', 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 - ) - frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 - ) - frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3') + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') + frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + ), + ]) # Create cables 1-2 cable1 = Cable( @@ -1688,30 +1750,40 @@ class LegacyCablePathTests(CablePathTestCase): interface4 = Interface.objects.create(device=self.device, name='Interface 4') 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 - ) - frontport1_3 = FrontPort.objects.create( - device=self.device, name='Front Port 1:3', rear_port=rearport1, rear_port_position=3 - ) - frontport1_4 = FrontPort.objects.create( - device=self.device, name='Front Port 1:4', rear_port=rearport1, rear_port_position=4 - ) - 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 - ) - frontport2_3 = FrontPort.objects.create( - device=self.device, name='Front Port 2:3', rear_port=rearport2, rear_port_position=3 - ) - frontport2_4 = FrontPort.objects.create( - device=self.device, name='Front Port 2:4', rear_port=rearport2, rear_port_position=4 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport1_3 = FrontPort.objects.create(device=self.device, name='Front Port 1:3') + frontport1_4 = FrontPort.objects.create(device=self.device, name='Front Port 1:4') + frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') + frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + frontport2_3 = FrontPort.objects.create(device=self.device, name='Front Port 2:3') + frontport2_4 = FrontPort.objects.create(device=self.device, name='Front Port 2:4') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport1_3, front_port_position=1, rear_port=rearport1, rear_port_position=3, + ), + PortAssignment( + front_port=frontport1_4, front_port_position=1, rear_port=rearport1, rear_port_position=4, + ), + PortAssignment( + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2_3, front_port_position=1, rear_port=rearport2, rear_port_position=3, + ), + PortAssignment( + front_port=frontport2_4, front_port_position=1, rear_port=rearport2, rear_port_position=4, + ), + ]) # Create cables 1-2 cable1 = Cable( @@ -1858,22 +1930,28 @@ class LegacyCablePathTests(CablePathTestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - 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) - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', 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 - ) - frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 - ) - frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3') + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') + frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + ), + ]) cable2 = Cable( a_terminations=[rearport1], @@ -1937,22 +2015,28 @@ class LegacyCablePathTests(CablePathTestCase): 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') - 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) - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', 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 - ) - frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 - ) - frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3') + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') + frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + ), + ]) cable2 = Cable( a_terminations=[rearport1], @@ -2033,30 +2117,38 @@ class LegacyCablePathTests(CablePathTestCase): 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') - 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) - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) - rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) - rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', 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 - ) - frontport3 = FrontPort.objects.create( - device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 - ) - frontport4 = FrontPort.objects.create( - device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 - ) - frontport5 = FrontPort.objects.create( - device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1 - ) - frontport6 = FrontPort.objects.create( - device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3') + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4') + rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5') + rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') + frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') + frontport5 = FrontPort.objects.create(device=self.device, name='Front Port 5') + frontport6 = FrontPort.objects.create(device=self.device, name='Front Port 6') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + ), + PortAssignment( + front_port=frontport5, front_port_position=1, rear_port=rearport5, rear_port_position=1, + ), + PortAssignment( + front_port=frontport6, front_port_position=1, rear_port=rearport6, rear_port_position=1, + ), + ]) cable2 = Cable( a_terminations=[rearport1], @@ -2155,14 +2247,18 @@ class LegacyCablePathTests(CablePathTestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - 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 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + ]) cable1 = Cable( a_terminations=[interface1], @@ -2200,14 +2296,18 @@ class LegacyCablePathTests(CablePathTestCase): 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 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + ]) # Create cables cable1 = Cable( @@ -2246,14 +2346,18 @@ class LegacyCablePathTests(CablePathTestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - 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 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + ]) # Create cable 2 cable2 = Cable( @@ -2299,10 +2403,13 @@ class LegacyCablePathTests(CablePathTestCase): """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - 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 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + ]) # Create cables 1 and 2 cable1 = Cable( @@ -2404,22 +2511,28 @@ class LegacyCablePathTests(CablePathTestCase): ) interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') - 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) - rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1) - rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', 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 - ) - frontport3 = FrontPort.objects.create( - device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 - ) - frontport4 = FrontPort.objects.create( - device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + rearport3 = RearPort.objects.create(device=device, name='Rear Port 3') + rearport4 = RearPort.objects.create(device=device, name='Rear Port 4') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') + frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + ), + PortAssignment( + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + ), + ]) cable2 = Cable( a_terminations=[rearport1], From f49b88ad5effa017029bfe607c11a0762e81d296 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 19 Nov 2025 08:35:50 -0500 Subject: [PATCH 20/51] Permit FrontPort.positions to be null --- netbox/dcim/forms/model_forms.py | 14 +- .../migrations/0221_m2m_port_assignments.py | 6 +- .../migrations/0222_frontport_positions.py | 3 +- netbox/dcim/models/cables.py | 4 +- netbox/dcim/models/device_components.py | 9 +- netbox/dcim/tests/test_cablepaths.py | 158 +++++++++--------- 6 files changed, 105 insertions(+), 89 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 67cd58ad1..ce7721f3d 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1619,11 +1619,19 @@ class FrontPortForm(ModularDeviceComponentForm): def clean(self): super().clean() + # FrontPort with no positions cannot be mapped to more than one RearPort + if not self.cleaned_data['positions'] and len(self.cleaned_data['rear_ports']) > 1: + raise forms.ValidationError({ + 'positions': _("A front port with no positions cannot be mapped to multiple rear ports.") + }) + # Count of selected rear port & position pairs much match the assigned number of positions if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']: - raise forms.ValidationError( - _("The number of rear port/position pairs selected must match the number of positions assigned.") - ) + raise forms.ValidationError({ + 'rear_ports': _( + "The number of rear port/position pairs selected must match the number of positions assigned." + ) + }) def _save_m2m(self): super()._save_m2m() diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index d6c3710d4..b925959d8 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -22,7 +22,7 @@ def populate_port_assignments(apps, schema_editor): for front_port in front_ports: yield PortAssignment( front_port_id=front_port.pk, - front_port_position=1, + front_port_position=None, rear_port_id=front_port.rear_port_id, rear_port_position=front_port.rear_port_position, ) @@ -45,9 +45,11 @@ class Migration(migrations.Migration): ( 'front_port_position', models.PositiveSmallIntegerField( + blank=True, + null=True, validators=[ django.core.validators.MinValueValidator(1), - django.core.validators.MaxValueValidator(1024), + django.core.validators.MaxValueValidator(1024) ] ), ), diff --git a/netbox/dcim/migrations/0222_frontport_positions.py b/netbox/dcim/migrations/0222_frontport_positions.py index 93421ee47..c98f9ab96 100644 --- a/netbox/dcim/migrations/0222_frontport_positions.py +++ b/netbox/dcim/migrations/0222_frontport_positions.py @@ -25,7 +25,8 @@ class Migration(migrations.Migration): model_name='frontport', name='positions', field=models.PositiveSmallIntegerField( - default=1, + 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 33d8d986b..517cfd7ca 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -786,7 +786,7 @@ class CablePath(models.Model): port_assignments = PortAssignment.objects.filter(front_port__in=remote_terminations) if not port_assignments: - print('No front-to-rear port assignments found') + print(f'No front-to-rear port assignments found for {remote_terminations}') break position_stack.append([assignment.rear_port_position for assignment in port_assignments]) terminations = [assignment.rear_port for assignment in port_assignments] @@ -804,7 +804,7 @@ class CablePath(models.Model): port_assignments = PortAssignment.objects.filter(rear_port__in=remote_terminations) if not port_assignments: - print('No rear-to-front port assignments found') + print(f'No rear-to-front port assignments found for {remote_terminations}') break position_stack.append([assignment.front_port_position for assignment in port_assignments]) terminations = [assignment.front_port for assignment in port_assignments] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3c054891b..3b5753c77 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1079,10 +1079,12 @@ class PortAssignment(models.Model): on_delete=models.CASCADE, ) front_port_position = models.PositiveSmallIntegerField( + blank=True, + null=True, validators=( MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX), - ) + ), ) rear_port = models.ForeignKey( to='dcim.RearPort', @@ -1092,7 +1094,7 @@ class PortAssignment(models.Model): validators=( MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX), - ) + ), ) class Meta: @@ -1146,7 +1148,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): ) positions = models.PositiveSmallIntegerField( verbose_name=_('positions'), - default=1, + blank=True, + null=True, validators=[ MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index a36688e07..e9322980b 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -285,7 +285,7 @@ class LegacyCablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.create( front_port=frontport1, - front_port_position=1, + front_port_position=None, rear_port=rearport1, rear_port_position=1 ) @@ -348,7 +348,7 @@ class LegacyCablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.create( front_port=frontport1, - front_port_position=1, + front_port_position=None, rear_port=rearport1, rear_port_position=1 ) @@ -417,16 +417,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, ), ]) @@ -541,16 +541,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, ), ]) @@ -711,22 +711,22 @@ class LegacyCablePathTests(CablePathTestCase): frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, + front_port=frontport4_1, front_port_position=None, rear_port=rearport4, rear_port_position=1, ), PortAssignment( - front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, + front_port=frontport4_2, front_port_position=None, rear_port=rearport4, rear_port_position=2, ), ]) @@ -839,28 +839,28 @@ class LegacyCablePathTests(CablePathTestCase): frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, ), PortAssignment( - front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3_1, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, + front_port=frontport3_2, front_port_position=None, rear_port=rearport3, rear_port_position=2, ), PortAssignment( - front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, + front_port=frontport4_1, front_port_position=None, rear_port=rearport4, rear_port_position=1, ), PortAssignment( - front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, + front_port=frontport4_2, front_port_position=None, rear_port=rearport4, rear_port_position=2, ), ]) @@ -966,28 +966,28 @@ class LegacyCablePathTests(CablePathTestCase): 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=4) - rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5') - rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4) frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') - frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 5') - frontport3_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') - frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + frontport3_1 = FrontPort.objects.create(device=self.device, name='Front Port 3:1') + frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3_1, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, + front_port=frontport3_2, front_port_position=None, rear_port=rearport3, rear_port_position=2, ), ]) @@ -1025,6 +1025,8 @@ class LegacyCablePathTests(CablePathTestCase): b_terminations=[rearport3] ) cable4.save() + for path in CablePath.objects.all(): + print(path.path_objects) self.assertPathExists( ( interface1, cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, @@ -1082,10 +1084,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), ]) @@ -1151,7 +1153,7 @@ class LegacyCablePathTests(CablePathTestCase): rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.create( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ) # Create cables @@ -1469,16 +1471,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, ), ]) circuittermination1 = CircuitTermination.objects.create( @@ -1667,16 +1669,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, ), ]) @@ -1760,28 +1762,28 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_4 = FrontPort.objects.create(device=self.device, name='Front Port 2:4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport1_3, front_port_position=1, rear_port=rearport1, rear_port_position=3, + front_port=frontport1_3, front_port_position=None, rear_port=rearport1, rear_port_position=3, ), PortAssignment( - front_port=frontport1_4, front_port_position=1, rear_port=rearport1, rear_port_position=4, + front_port=frontport1_4, front_port_position=None, rear_port=rearport1, rear_port_position=4, ), PortAssignment( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, ), PortAssignment( - front_port=frontport2_3, front_port_position=1, rear_port=rearport2, rear_port_position=3, + front_port=frontport2_3, front_port_position=None, rear_port=rearport2, rear_port_position=3, ), PortAssignment( - front_port=frontport2_4, front_port_position=1, rear_port=rearport2, rear_port_position=4, + front_port=frontport2_4, front_port_position=None, rear_port=rearport2, rear_port_position=4, ), ]) @@ -1940,16 +1942,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, ), ]) @@ -2025,16 +2027,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, ), ]) @@ -2131,22 +2133,22 @@ class LegacyCablePathTests(CablePathTestCase): frontport6 = FrontPort.objects.create(device=self.device, name='Front Port 6') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, ), PortAssignment( - front_port=frontport5, front_port_position=1, rear_port=rearport5, rear_port_position=1, + front_port=frontport5, front_port_position=None, rear_port=rearport5, rear_port_position=1, ), PortAssignment( - front_port=frontport6, front_port_position=1, rear_port=rearport6, rear_port_position=1, + front_port=frontport6, front_port_position=None, rear_port=rearport6, rear_port_position=1, ), ]) @@ -2253,10 +2255,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2302,10 +2304,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2352,10 +2354,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2407,7 +2409,7 @@ class LegacyCablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), ]) @@ -2521,16 +2523,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, ), ]) From 9dbb9bb51c9d0218dc4b76a68ecc13f4c29b237d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 19 Nov 2025 16:20:44 -0500 Subject: [PATCH 21/51] Update cable path tests --- netbox/dcim/models/cables.py | 76 ++++-------------- netbox/dcim/tests/test_cablepaths.py | 4 +- netbox/dcim/tests/test_cablepaths2.py | 109 ++++++++++++++------------ 3 files changed, 77 insertions(+), 112 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 517cfd7ca..92fda318f 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -695,7 +695,9 @@ class CablePath(models.Model): 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] + links = list(dict.fromkeys( + termination.link for termination in terminations if termination.link is not None + )) if len(links) == 0: if len(path) == 1: # If this is the start of the path and no link exists, return None @@ -775,83 +777,39 @@ class CablePath(models.Model): if isinstance(remote_terminations[0], FrontPort): # Follow FrontPorts to their corresponding RearPorts - if position_stack: - positions = position_stack.pop() + if any(rt.positions for rt in remote_terminations): q_filter = Q() for rt in remote_terminations: - position = positions.pop() - q_filter |= Q(front_port=rt, front_port_position=position) + q_filter |= Q(front_port=rt, front_port_position__in=rt.positions) port_assignments = PortAssignment.objects.filter(q_filter) else: port_assignments = PortAssignment.objects.filter(front_port__in=remote_terminations) - if not port_assignments: - print(f'No front-to-rear port assignments found for {remote_terminations}') break - position_stack.append([assignment.rear_port_position for assignment in port_assignments]) - terminations = [assignment.rear_port for assignment in port_assignments] + + # Compile the list of RearPorts without duplication or altering their ordering + terminations = list(dict.fromkeys(assignment.rear_port for assignment in port_assignments)) + # if not(len(terminations) == 1 and terminations[0].positions == 1): + if any(t.positions > 1 for t in terminations): + position_stack.append([assignment.rear_port_position for assignment in port_assignments]) elif isinstance(remote_terminations[0], RearPort): # Follow RearPorts to their corresponding FrontPorts - if position_stack: + if remote_terminations[0].positions > 1 and position_stack: positions = position_stack.pop() q_filter = Q() for rt in remote_terminations: - position = positions.pop() - q_filter |= Q(rear_port=rt, rear_port_position=position) + q_filter |= Q(rear_port=rt, rear_port_position__in=positions) port_assignments = PortAssignment.objects.filter(q_filter) + elif remote_terminations[0].positions > 1: + is_split = True + break else: port_assignments = PortAssignment.objects.filter(rear_port__in=remote_terminations) - if not port_assignments: - print(f'No rear-to-front port assignments found for {remote_terminations}') break - position_stack.append([assignment.front_port_position for assignment in port_assignments]) - terminations = [assignment.front_port for assignment in port_assignments] - # if len(remote_terminations) == 1 and remote_terminations[0].positions == 1: - # front_ports = FrontPort.objects.filter( - # rear_port_id__in=[rp.pk for rp in remote_terminations], - # rear_port_position=1 - # ) - # # Obtain the individual front ports based on the termination and all positions - # elif len(remote_terminations) > 1 and position_stack: - # positions = position_stack.pop() - # - # # Ensure we have a number of positions equal to the amount of remote terminations - # if len(remote_terminations) != len(positions): - # raise UnsupportedCablePath( - # _("All positions counts within the path on opposite ends of links must match") - # ) - # - # # Get our front ports - # q_filter = Q() - # for rt in remote_terminations: - # position = positions.pop() - # q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) - # if q_filter is Q(): - # raise UnsupportedCablePath(_("Remote termination position filter is missing")) - # front_ports = FrontPort.objects.filter(q_filter) - # # Obtain the individual front ports based on the termination and position - # elif position_stack: - # front_ports = FrontPort.objects.filter( - # rear_port_id=remote_terminations[0].pk, - # rear_port_position__in=position_stack.pop() - # ) - # # If all rear ports have a single position, we can just get the front ports - # elif all([rp.positions == 1 for rp in remote_terminations]): - # front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations]) - # - # if len(front_ports) != len(remote_terminations): - # # Some rear ports does not have a front port - # is_split = True - # break - # else: - # # No position indicated: path has split, so we stop at the RearPorts - # is_split = True - # break - # - # terminations = front_ports + terminations = [assignment.front_port for assignment in port_assignments] elif isinstance(remote_terminations[0], CircuitTermination): # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index e9322980b..fe7a6aea7 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1025,8 +1025,6 @@ class LegacyCablePathTests(CablePathTestCase): b_terminations=[rearport3] ) cable4.save() - for path in CablePath.objects.all(): - print(path.path_objects) self.assertPathExists( ( interface1, cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, @@ -1079,7 +1077,7 @@ class LegacyCablePathTests(CablePathTestCase): 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') - rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2) frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') PortAssignment.objects.bulk_create([ diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index c7895c6d2..44bf4f082 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -1,5 +1,3 @@ -from unittest import skipIf - from circuits.models import CircuitTermination from dcim.choices import CableProfileChoices from dcim.models import * @@ -363,13 +361,13 @@ class CablePathTests(CablePathTestCase): 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 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + ), + ]) # Create cables cable1 = Cable( @@ -439,18 +437,24 @@ class CablePathTests(CablePathTestCase): ] 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 - ) + frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') + frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') + frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') + frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + ), + PortAssignment( + front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, + ), + ]) # Create cables cable1 = Cable( @@ -654,25 +658,31 @@ class CablePathTests(CablePathTestCase): 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), + RearPort.objects.create(device=self.device, name='Rear Port 1'), + RearPort.objects.create(device=self.device, name='Rear Port 2'), + RearPort.objects.create(device=self.device, name='Rear Port 3'), + RearPort.objects.create(device=self.device, name='Rear Port 4'), ] 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 - ), + FrontPort.objects.create(device=self.device, name='Front Port 1'), + FrontPort.objects.create(device=self.device, name='Front Port 2'), + FrontPort.objects.create(device=self.device, name='Front Port 3'), + FrontPort.objects.create(device=self.device, name='Front Port 4'), ] + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=front_ports[0], front_port_position=None, rear_port=rear_ports[0], rear_port_position=1, + ), + PortAssignment( + front_port=front_ports[1], front_port_position=None, rear_port=rear_ports[1], rear_port_position=1, + ), + PortAssignment( + front_port=front_ports[2], front_port_position=None, rear_port=rear_ports[2], rear_port_position=1, + ), + PortAssignment( + front_port=front_ports[3], front_port_position=None, rear_port=rear_ports[3], rear_port_position=1, + ), + ]) # Create cables cable1 = Cable( @@ -723,8 +733,6 @@ class CablePathTests(CablePathTestCase): # 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] @@ -736,14 +744,18 @@ class CablePathTests(CablePathTestCase): 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 - ) + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + PortAssignment.objects.bulk_create([ + PortAssignment( + front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + ), + PortAssignment( + front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + ), + ]) # Create cables cable1 = Cable( @@ -761,9 +773,6 @@ class CablePathTests(CablePathTestCase): 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]), From 4f54b29f4827999c1d7cdaca9b57095f2889dc74 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Nov 2025 08:34:59 -0500 Subject: [PATCH 22/51] Default FrontPort.positions to 1, to match RearPort --- .../migrations/0222_frontport_positions.py | 3 +- netbox/dcim/models/cables.py | 25 ++- netbox/dcim/models/device_components.py | 3 +- netbox/dcim/tests/test_cablepaths.py | 146 +++++++++--------- netbox/dcim/tests/test_cablepaths2.py | 22 +-- 5 files changed, 107 insertions(+), 92 deletions(-) diff --git a/netbox/dcim/migrations/0222_frontport_positions.py b/netbox/dcim/migrations/0222_frontport_positions.py index c98f9ab96..93421ee47 100644 --- a/netbox/dcim/migrations/0222_frontport_positions.py +++ b/netbox/dcim/migrations/0222_frontport_positions.py @@ -25,8 +25,7 @@ class Migration(migrations.Migration): model_name='frontport', name='positions', field=models.PositiveSmallIntegerField( - blank=True, - null=True, + default=1, 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 92fda318f..216358ac0 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -667,7 +667,14 @@ class CablePath(models.Model): is_active = True is_split = False + DEBUG = False + + segment = 0 while terminations: + segment += 1 + if DEBUG: + print(f'[#{segment}] Position stack: {position_stack}') + print(f'[#{segment}] Local terminations: {terminations}') # Terminations must all be of the same type if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]): @@ -698,6 +705,8 @@ class CablePath(models.Model): links = list(dict.fromkeys( termination.link for termination in terminations if termination.link is not None )) + if DEBUG: + print(f'[#{segment}] Links: {links}') if len(links) == 0: if len(path) == 1: # If this is the start of the path and no link exists, return None @@ -760,6 +769,8 @@ class CablePath(models.Model): link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links ] + if DEBUG: + print(f'[#{segment}] Remote terminations: {remote_terminations}') # Remote Terminations must all be of the same type, otherwise return a split path if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): is_complete = False @@ -777,11 +788,15 @@ class CablePath(models.Model): if isinstance(remote_terminations[0], FrontPort): # Follow FrontPorts to their corresponding RearPorts - if any(rt.positions for rt in remote_terminations): + if remote_terminations[0].positions > 1 and position_stack: + positions = position_stack.pop() q_filter = Q() for rt in remote_terminations: - q_filter |= Q(front_port=rt, front_port_position__in=rt.positions) + q_filter |= Q(front_port=rt, front_port_position__in=positions) port_assignments = PortAssignment.objects.filter(q_filter) + elif remote_terminations[0].positions > 1: + is_split = True + break else: port_assignments = PortAssignment.objects.filter(front_port__in=remote_terminations) if not port_assignments: @@ -789,7 +804,6 @@ class CablePath(models.Model): # Compile the list of RearPorts without duplication or altering their ordering terminations = list(dict.fromkeys(assignment.rear_port for assignment in port_assignments)) - # if not(len(terminations) == 1 and terminations[0].positions == 1): if any(t.positions > 1 for t in terminations): position_stack.append([assignment.rear_port_position for assignment in port_assignments]) @@ -809,7 +823,10 @@ class CablePath(models.Model): if not port_assignments: break - terminations = [assignment.front_port for assignment in port_assignments] + # Compile the list of FrontPorts without duplication or altering their ordering + terminations = list(dict.fromkeys(assignment.front_port for assignment in port_assignments)) + if any(t.positions > 1 for t in terminations): + position_stack.append([assignment.front_port_position for assignment in port_assignments]) elif isinstance(remote_terminations[0], CircuitTermination): # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3b5753c77..49f0df81c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1148,8 +1148,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): ) positions = models.PositiveSmallIntegerField( verbose_name=_('positions'), - blank=True, - null=True, + default=1, validators=[ MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index fe7a6aea7..f3c3d73c5 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -285,7 +285,7 @@ class LegacyCablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.create( front_port=frontport1, - front_port_position=None, + front_port_position=1, rear_port=rearport1, rear_port_position=1 ) @@ -348,7 +348,7 @@ class LegacyCablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.create( front_port=frontport1, - front_port_position=None, + front_port_position=1, rear_port=rearport1, rear_port_position=1 ) @@ -417,16 +417,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) @@ -541,16 +541,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) @@ -711,22 +711,22 @@ class LegacyCablePathTests(CablePathTestCase): frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4_1, front_port_position=None, rear_port=rearport4, rear_port_position=1, + front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), PortAssignment( - front_port=frontport4_2, front_port_position=None, rear_port=rearport4, rear_port_position=2, + front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, ), ]) @@ -839,28 +839,28 @@ class LegacyCablePathTests(CablePathTestCase): frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), PortAssignment( - front_port=frontport3_1, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport3_2, front_port_position=None, rear_port=rearport3, rear_port_position=2, + front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, ), PortAssignment( - front_port=frontport4_1, front_port_position=None, rear_port=rearport4, rear_port_position=1, + front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), PortAssignment( - front_port=frontport4_2, front_port_position=None, rear_port=rearport4, rear_port_position=2, + front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, ), ]) @@ -975,19 +975,19 @@ class LegacyCablePathTests(CablePathTestCase): frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3_1, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport3_2, front_port_position=None, rear_port=rearport3, rear_port_position=2, + front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, ), ]) @@ -1082,10 +1082,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), ]) @@ -1151,7 +1151,7 @@ class LegacyCablePathTests(CablePathTestCase): rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.create( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ) # Create cables @@ -1469,16 +1469,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) circuittermination1 = CircuitTermination.objects.create( @@ -1667,16 +1667,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) @@ -1760,28 +1760,28 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_4 = FrontPort.objects.create(device=self.device, name='Front Port 2:4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport1_3, front_port_position=None, rear_port=rearport1, rear_port_position=3, + front_port=frontport1_3, front_port_position=1, rear_port=rearport1, rear_port_position=3, ), PortAssignment( - front_port=frontport1_4, front_port_position=None, rear_port=rearport1, rear_port_position=4, + front_port=frontport1_4, front_port_position=1, rear_port=rearport1, rear_port_position=4, ), PortAssignment( - front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), PortAssignment( - front_port=frontport2_3, front_port_position=None, rear_port=rearport2, rear_port_position=3, + front_port=frontport2_3, front_port_position=1, rear_port=rearport2, rear_port_position=3, ), PortAssignment( - front_port=frontport2_4, front_port_position=None, rear_port=rearport2, rear_port_position=4, + front_port=frontport2_4, front_port_position=1, rear_port=rearport2, rear_port_position=4, ), ]) @@ -1940,16 +1940,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) @@ -2025,16 +2025,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) @@ -2131,22 +2131,22 @@ class LegacyCablePathTests(CablePathTestCase): frontport6 = FrontPort.objects.create(device=self.device, name='Front Port 6') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), PortAssignment( - front_port=frontport5, front_port_position=None, rear_port=rearport5, rear_port_position=1, + front_port=frontport5, front_port_position=1, rear_port=rearport5, rear_port_position=1, ), PortAssignment( - front_port=frontport6, front_port_position=None, rear_port=rearport6, rear_port_position=1, + front_port=frontport6, front_port_position=1, rear_port=rearport6, rear_port_position=1, ), ]) @@ -2253,10 +2253,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2302,10 +2302,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2352,10 +2352,10 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2407,7 +2407,7 @@ class LegacyCablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), ]) @@ -2521,16 +2521,16 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport3, front_port_position=None, rear_port=rearport3, rear_port_position=1, + front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), PortAssignment( - front_port=frontport4, front_port_position=None, rear_port=rearport4, rear_port_position=1, + front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 44bf4f082..8a75fdcb5 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -365,7 +365,7 @@ class CablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), ]) @@ -443,16 +443,16 @@ class CablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1_1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport1_2, front_port_position=None, rear_port=rearport1, rear_port_position=2, + front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), PortAssignment( - front_port=frontport2_1, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), PortAssignment( - front_port=frontport2_2, front_port_position=None, rear_port=rearport2, rear_port_position=2, + front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) @@ -671,16 +671,16 @@ class CablePathTests(CablePathTestCase): ] PortAssignment.objects.bulk_create([ PortAssignment( - front_port=front_ports[0], front_port_position=None, rear_port=rear_ports[0], rear_port_position=1, + front_port=front_ports[0], front_port_position=1, rear_port=rear_ports[0], rear_port_position=1, ), PortAssignment( - front_port=front_ports[1], front_port_position=None, rear_port=rear_ports[1], rear_port_position=1, + front_port=front_ports[1], front_port_position=1, rear_port=rear_ports[1], rear_port_position=1, ), PortAssignment( - front_port=front_ports[2], front_port_position=None, rear_port=rear_ports[2], rear_port_position=1, + front_port=front_ports[2], front_port_position=1, rear_port=rear_ports[2], rear_port_position=1, ), PortAssignment( - front_port=front_ports[3], front_port_position=None, rear_port=rear_ports[3], rear_port_position=1, + front_port=front_ports[3], front_port_position=1, rear_port=rear_ports[3], rear_port_position=1, ), ]) @@ -750,10 +750,10 @@ class CablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortAssignment.objects.bulk_create([ PortAssignment( - front_port=frontport1, front_port_position=None, rear_port=rearport1, rear_port_position=1, + front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), PortAssignment( - front_port=frontport2, front_port_position=None, rear_port=rearport2, rear_port_position=1, + front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) From f067122ccd945d1610ccea7bbac76012eb1dc0b7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Nov 2025 15:32:11 -0500 Subject: [PATCH 23/51] Add PortAssignmentTemplate for device types --- netbox/dcim/filtersets.py | 13 +- netbox/dcim/forms/model_forms.py | 19 +-- netbox/dcim/forms/object_import.py | 22 +-- .../migrations/0221_m2m_port_assignments.py | 127 ++++++++++++++++-- .../migrations/0222_frontport_positions.py | 30 +++++ .../dcim/models/device_component_templates.py | 120 ++++++++++------- 6 files changed, 235 insertions(+), 96 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 52ee68929..4a7463fdd 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -884,13 +884,14 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=PortTypeChoices, null_value=None ) - rear_port_id = django_filters.ModelMultipleChoiceFilter( - queryset=RearPort.objects.all() - ) + # TODO + # rear_port_id = django_filters.ModelMultipleChoiceFilter( + # queryset=RearPortTemplate.objects.all() + # ) class Meta: model = FrontPortTemplate - fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description') + fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): @@ -898,6 +899,10 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom choices=PortTypeChoices, null_value=None ) + # TODO + # front_port_id = django_filters.ModelMultipleChoiceFilter( + # queryset=FrontPortTemplate.objects.all() + # ) class Meta: model = RearPortTemplate diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index ce7721f3d..6d8c4ba42 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1112,14 +1112,10 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): class FrontPortTemplateForm(ModularComponentTemplateForm): - rear_port = DynamicModelChoiceField( - label=_('Rear port'), - queryset=RearPortTemplate.objects.all(), - required=False, - query_params={ - 'device_type_id': '$device_type', - 'module_type_id': '$module_type', - } + rear_ports = forms.MultipleChoiceField( + choices=[], + label=_('Rear ports'), + widget=forms.SelectMultiple(attrs={'size': 8}) ) fieldsets = ( @@ -1128,15 +1124,14 @@ class FrontPortTemplateForm(ModularComponentTemplateForm): FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', + 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description', ), ) class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] @@ -1581,7 +1576,7 @@ class FrontPortForm(ModularDeviceComponentForm): rear_ports = forms.MultipleChoiceField( choices=[], label=_('Rear ports'), - widget=forms.SelectMultiple(attrs={'size': 6}) + widget=forms.SelectMultiple(attrs={'size': 8}) ) fieldsets = ( diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 3f2cc3ef6..a0de2ad24 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -113,31 +113,11 @@ class FrontPortTemplateImportForm(forms.ModelForm): label=_('Type'), choices=PortTypeChoices.CHOICES ) - rear_port = forms.ModelChoiceField( - label=_('Rear port'), - queryset=RearPortTemplate.objects.all(), - to_field_name='name' - ) - - def clean_device_type(self): - if device_type := self.cleaned_data['device_type']: - rear_port = self.fields['rear_port'] - rear_port.queryset = rear_port.queryset.filter(device_type=device_type) - - return device_type - - def clean_module_type(self): - if module_type := self.cleaned_data['module_type']: - rear_port = self.fields['rear_port'] - rear_port.queryset = rear_port.queryset.filter(module_type=module_type) - - return module_type class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label', - 'description', + 'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description', ] diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index b925959d8..d9030cd4e 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -6,12 +6,34 @@ from itertools import islice def chunked(iterable, size): - """Yield successive chunks of a given size from an iterator.""" + """ + Yield successive chunks of a given size from an iterator. + """ iterator = iter(iterable) while chunk := list(islice(iterator, size)): yield chunk +def populate_port_template_assignments(apps, schema_editor): + FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate') + PortAssignmentTemplate = apps.get_model('dcim', 'PortAssignmentTemplate') + + front_ports = FrontPortTemplate.objects.iterator(chunk_size=1000) + + def generate_copies(): + for front_port in front_ports: + yield PortAssignmentTemplate( + front_port_id=front_port.pk, + front_port_position=None, + rear_port_id=front_port.rear_port_id, + rear_port_position=front_port.rear_port_position, + ) + + # Bulk insert in streaming batches + for chunk in chunked(generate_copies(), 1000): + PortAssignmentTemplate.objects.bulk_create(chunk, batch_size=1000) + + def populate_port_assignments(apps, schema_editor): FrontPort = apps.get_model('dcim', 'FrontPort') PortAssignment = apps.get_model('dcim', 'PortAssignment') @@ -38,6 +60,68 @@ class Migration(migrations.Migration): ] operations = [ + # Create PortAssignmentTemplate model (for DeviceTypes) + migrations.CreateModel( + name='PortAssignmentTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ( + 'front_port_position', + models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024) + ] + ) + ), + ( + 'rear_port_position', + models.PositiveSmallIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024) + ] + ) + ), + ( + 'front_port', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.frontporttemplate') + ), + ( + 'rear_port', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.rearporttemplate') + ), + ], + ), + migrations.AddConstraint( + model_name='portassignmenttemplate', + constraint=models.UniqueConstraint( + fields=('front_port', 'front_port_position'), + name='dcim_portassignmenttemplate_unique_front_port_position' + ), + ), + migrations.AddConstraint( + model_name='portassignmenttemplate', + constraint=models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='dcim_portassignmenttemplate_unique_rear_port_position' + ), + ), + + # Add rear_ports ManyToManyField on FrontPortTemplate + migrations.AddField( + model_name='frontporttemplate', + name='rear_ports', + field=models.ManyToManyField( + related_name='front_ports', + through='dcim.PortAssignmentTemplate', + to='dcim.rearporttemplate' + ), + ), + + # Create PortAssignment model (for Devices) migrations.CreateModel( name='PortAssignment', fields=[ @@ -66,22 +150,39 @@ class Migration(migrations.Migration): ('rear_port', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.rearport')), ], ), + migrations.AddConstraint( + model_name='portassignment', + constraint=models.UniqueConstraint( + fields=('front_port', 'front_port_position'), + name='dcim_portassignment_unique_front_port_position' + ), + ), + migrations.AddConstraint( + model_name='portassignment', + constraint=models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='dcim_portassignment_unique_rear_port_position' + ), + ), + + # Add rear_ports ManyToManyField on FrontPort migrations.AddField( model_name='frontport', name='rear_ports', - field=models.ManyToManyField(related_name='front_ports', through='dcim.PortAssignment', to='dcim.rearport'), - ), - migrations.AddConstraint( - model_name='portassignment', - constraint=models.UniqueConstraint( - fields=('front_port', 'front_port_position'), name='dcim_portassignment_unique_front_port_position' + field=models.ManyToManyField( + related_name='front_ports', + through='dcim.PortAssignment', + to='dcim.rearport' ), ), - migrations.AddConstraint( - model_name='portassignment', - constraint=models.UniqueConstraint( - fields=('rear_port', 'rear_port_position'), name='dcim_portassignment_unique_rear_port_position' - ), + + # Data migration + migrations.RunPython( + code=populate_port_template_assignments, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=populate_port_assignments, + reverse_code=migrations.RunPython.noop ), - migrations.RunPython(code=populate_port_assignments, reverse_code=migrations.RunPython.noop), ] diff --git a/netbox/dcim/migrations/0222_frontport_positions.py b/netbox/dcim/migrations/0222_frontport_positions.py index 93421ee47..a336968d5 100644 --- a/netbox/dcim/migrations/0222_frontport_positions.py +++ b/netbox/dcim/migrations/0222_frontport_positions.py @@ -9,6 +9,34 @@ class Migration(migrations.Migration): ] operations = [ + # Remove rear_port & rear_port_position from FrontPortTemplate + migrations.RemoveConstraint( + model_name='frontporttemplate', + name='dcim_frontporttemplate_unique_rear_port_position', + ), + migrations.RemoveField( + model_name='frontporttemplate', + name='rear_port', + ), + migrations.RemoveField( + model_name='frontporttemplate', + name='rear_port_position', + ), + + # Add positions on FrontPortTemplate + migrations.AddField( + model_name='frontporttemplate', + name='positions', + field=models.PositiveSmallIntegerField( + default=1, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024) + ] + ), + ), + + # Remove rear_port & rear_port_position from FrontPort migrations.RemoveConstraint( model_name='frontport', name='dcim_frontport_unique_rear_port_position', @@ -21,6 +49,8 @@ class Migration(migrations.Migration): model_name='frontport', name='rear_port_position', ), + + # Add positions on FrontPort migrations.AddField( model_name='frontport', name='positions', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 177da1765..d9f70ee25 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -518,6 +518,69 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) } +class PortAssignmentTemplate(models.Model): + """ + Maps a FrontPortTemplate & position to a RearPortTemplate & position. + """ + front_port = models.ForeignKey( + to='dcim.FrontPortTemplate', + on_delete=models.CASCADE, + ) + front_port_position = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ), + ) + rear_port = models.ForeignKey( + to='dcim.RearPortTemplate', + on_delete=models.CASCADE, + ) + rear_port_position = models.PositiveSmallIntegerField( + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ), + ) + + class Meta: + constraints = ( + models.UniqueConstraint( + fields=('front_port', 'front_port_position'), + name='%(app_label)s_%(class)s_unique_front_port_position' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), + ) + + def clean(self): + + # Validate rear port assignment + if self.front_port.device_type_id != self.rear_port.device_type_id: + raise ValidationError({ + "rear_port": _("Rear port ({rear_port}) must belong to the same device type").format( + rear_port=self.rear_port + ) + }) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError({ + "rear_port_position": _( + "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} " + "positions." + ).format( + rear_port_position=self.rear_port_position, + name=self.rear_port.name, + positions=self.rear_port.positions + ) + }) + + class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -531,18 +594,18 @@ class FrontPortTemplate(ModularComponentTemplateModel): verbose_name=_('color'), blank=True ) - rear_port = models.ForeignKey( - to='dcim.RearPortTemplate', - on_delete=models.CASCADE, - related_name='frontport_templates' - ) - rear_port_position = models.PositiveSmallIntegerField( - verbose_name=_('rear port position'), + positions = models.PositiveSmallIntegerField( + verbose_name=_('positions'), default=1, validators=[ MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) - ] + ], + ) + rear_ports = models.ManyToManyField( + to='dcim.RearPortTemplate', + through='dcim.PortAssignmentTemplate', + related_name='front_ports', ) component_model = FrontPort @@ -557,51 +620,17 @@ class FrontPortTemplate(ModularComponentTemplateModel): fields=('module_type', 'name'), name='%(app_label)s_%(class)s_unique_module_type_name' ), - models.UniqueConstraint( - fields=('rear_port', 'rear_port_position'), - name='%(app_label)s_%(class)s_unique_rear_port_position' - ), ) verbose_name = _('front port template') verbose_name_plural = _('front port templates') - def clean(self): - super().clean() - - try: - - # Validate rear port assignment - if self.rear_port.device_type != self.device_type: - raise ValidationError( - _("Rear port ({name}) must belong to the same device type").format(name=self.rear_port) - ) - - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError( - _("Invalid rear port position ({position}); rear port {name} has only {count} positions").format( - position=self.rear_port_position, - name=self.rear_port.name, - count=self.rear_port.positions - ) - ) - - except RearPortTemplate.DoesNotExist: - pass - def instantiate(self, **kwargs): - if self.rear_port: - rear_port_name = self.rear_port.resolve_name(kwargs.get('module')) - rear_port = RearPort.objects.get(name=rear_port_name, **kwargs) - else: - rear_port = None return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.resolve_label(kwargs.get('module')), type=self.type, color=self.color, - rear_port=rear_port, - rear_port_position=self.rear_port_position, + positions=self.positions, **kwargs ) instantiate.do_not_call_in_templates = True @@ -611,8 +640,7 @@ class FrontPortTemplate(ModularComponentTemplateModel): 'name': self.name, 'type': self.type, 'color': self.color, - 'rear_port': self.rear_port.name, - 'rear_port_position': self.rear_port_position, + 'positions': self.positions, 'label': self.label, 'description': self.description, } @@ -637,7 +665,7 @@ class RearPortTemplate(ModularComponentTemplateModel): validators=[ MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX) - ] + ], ) component_model = RearPort From 1e0748e61852f8fe12b1857916d8aef63c2a0c81 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Nov 2025 15:43:18 -0500 Subject: [PATCH 24/51] Refactor PortAssignment and PortAssignmentTemplate into PortAssignmentBase --- .../migrations/0221_m2m_port_assignments.py | 4 -- netbox/dcim/models/base.py | 57 +++++++++++++++++++ .../dcim/models/device_component_templates.py | 44 ++------------ netbox/dcim/models/device_components.py | 43 +------------- 4 files changed, 64 insertions(+), 84 deletions(-) create mode 100644 netbox/dcim/models/base.py diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index d9030cd4e..7b29137d4 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -68,8 +68,6 @@ class Migration(migrations.Migration): ( 'front_port_position', models.PositiveSmallIntegerField( - blank=True, - null=True, validators=[ django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024) @@ -129,8 +127,6 @@ class Migration(migrations.Migration): ( 'front_port_position', models.PositiveSmallIntegerField( - blank=True, - null=True, validators=[ django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024) diff --git a/netbox/dcim/models/base.py b/netbox/dcim/models/base.py new file mode 100644 index 000000000..41ef27131 --- /dev/null +++ b/netbox/dcim/models/base.py @@ -0,0 +1,57 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from dcim.constants import PORT_POSITION_MAX, PORT_POSITION_MIN + +__all__ = ( + 'PortAssignmentBase', +) + + +class PortAssignmentBase(models.Model): + """ + Base class for PortAssignment and PortAssignment Template + """ + front_port_position = models.PositiveSmallIntegerField( + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ), + ) + rear_port_position = models.PositiveSmallIntegerField( + validators=( + MinValueValidator(PORT_POSITION_MIN), + MaxValueValidator(PORT_POSITION_MAX), + ), + ) + + class Meta: + abstract = True + constraints = ( + models.UniqueConstraint( + fields=('front_port', 'front_port_position'), + name='%(app_label)s_%(class)s_unique_front_port_position' + ), + models.UniqueConstraint( + fields=('rear_port', 'rear_port_position'), + name='%(app_label)s_%(class)s_unique_rear_port_position' + ), + ) + + def clean(self): + super().clean() + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError({ + "rear_port_position": _( + "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} " + "positions." + ).format( + rear_port_position=self.rear_port_position, + name=self.rear_port.name, + positions=self.rear_port.positions + ) + }) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index d9f70ee25..6a6e967cf 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -7,6 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * +from dcim.models.base import PortAssignmentBase from dcim.models.mixins import InterfaceValidationMixin from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField @@ -28,6 +29,7 @@ __all__ = ( 'InterfaceTemplate', 'InventoryItemTemplate', 'ModuleBayTemplate', + 'PortAssignmentTemplate', 'PowerOutletTemplate', 'PowerPortTemplate', 'RearPortTemplate', @@ -518,7 +520,7 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) } -class PortAssignmentTemplate(models.Model): +class PortAssignmentTemplate(PortAssignmentBase): """ Maps a FrontPortTemplate & position to a RearPortTemplate & position. """ @@ -526,38 +528,13 @@ class PortAssignmentTemplate(models.Model): to='dcim.FrontPortTemplate', on_delete=models.CASCADE, ) - front_port_position = models.PositiveSmallIntegerField( - blank=True, - null=True, - validators=( - MinValueValidator(PORT_POSITION_MIN), - MaxValueValidator(PORT_POSITION_MAX), - ), - ) rear_port = models.ForeignKey( to='dcim.RearPortTemplate', on_delete=models.CASCADE, ) - rear_port_position = models.PositiveSmallIntegerField( - validators=( - MinValueValidator(PORT_POSITION_MIN), - MaxValueValidator(PORT_POSITION_MAX), - ), - ) - - class Meta: - constraints = ( - models.UniqueConstraint( - fields=('front_port', 'front_port_position'), - name='%(app_label)s_%(class)s_unique_front_port_position' - ), - models.UniqueConstraint( - fields=('rear_port', 'rear_port_position'), - name='%(app_label)s_%(class)s_unique_rear_port_position' - ), - ) def clean(self): + super().clean() # Validate rear port assignment if self.front_port.device_type_id != self.rear_port.device_type_id: @@ -567,19 +544,6 @@ class PortAssignmentTemplate(models.Model): ) }) - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError({ - "rear_port_position": _( - "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} " - "positions." - ).format( - rear_port_position=self.rear_port_position, - name=self.rear_port.name, - positions=self.rear_port.positions - ) - }) - class FrontPortTemplate(ModularComponentTemplateModel): """ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 49f0df81c..be340fde3 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,6 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.fields import WWNField +from dcim.models.base import PortAssignmentBase from dcim.models.mixins import InterfaceValidationMixin from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, NetBoxModel @@ -1070,7 +1071,7 @@ class Interface( # Pass-through ports # -class PortAssignment(models.Model): +class PortAssignment(PortAssignmentBase): """ Maps a FrontPort & position to a RearPort & position. """ @@ -1078,38 +1079,13 @@ class PortAssignment(models.Model): to='dcim.FrontPort', on_delete=models.CASCADE, ) - front_port_position = models.PositiveSmallIntegerField( - blank=True, - null=True, - validators=( - MinValueValidator(PORT_POSITION_MIN), - MaxValueValidator(PORT_POSITION_MAX), - ), - ) rear_port = models.ForeignKey( to='dcim.RearPort', on_delete=models.CASCADE, ) - rear_port_position = models.PositiveSmallIntegerField( - validators=( - MinValueValidator(PORT_POSITION_MIN), - MaxValueValidator(PORT_POSITION_MAX), - ), - ) - - class Meta: - constraints = ( - models.UniqueConstraint( - fields=('front_port', 'front_port_position'), - name='%(app_label)s_%(class)s_unique_front_port_position' - ), - models.UniqueConstraint( - fields=('rear_port', 'rear_port_position'), - name='%(app_label)s_%(class)s_unique_rear_port_position' - ), - ) def clean(self): + super().clean() # Validate rear port assignment if self.front_port.device_id != self.rear_port.device_id: @@ -1119,19 +1095,6 @@ class PortAssignment(models.Model): ) }) - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError({ - "rear_port_position": _( - "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} " - "positions." - ).format( - rear_port_position=self.rear_port_position, - name=self.rear_port.name, - positions=self.rear_port.positions - ) - }) - class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): """ From e71e4ef0ceb0c097b44829b38362c1db50daf203 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 20 Nov 2025 16:42:54 -0500 Subject: [PATCH 25/51] Replicate front/rear port assignments from DeviceType --- .../api/serializers_/devicetype_components.py | 6 +- netbox/dcim/forms/mixins.py | 51 ++++++++- netbox/dcim/forms/model_forms.py | 103 +++++++++--------- netbox/dcim/forms/object_create.py | 66 +++-------- netbox/dcim/models/devices.py | 7 +- netbox/dcim/utils.py | 20 ++++ 6 files changed, 145 insertions(+), 108 deletions(-) diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index b44565d65..ed2893ed4 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -243,13 +243,13 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer): default=None ) type = ChoiceField(choices=PortTypeChoices) - rear_port = RearPortTemplateSerializer(nested=True) + rear_ports = RearPortTemplateSerializer(nested=True, many=True) class Meta: model = FrontPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', - 'rear_port', 'rear_port_position', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', + 'rear_ports', 'description', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 96eb8a56b..075742642 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -4,7 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.constants import LOCATION_SCOPE_TYPES -from dcim.models import Site +from dcim.models import PortAssignmentTemplate, Site from utilities.forms import get_field_value from utilities.forms.fields import ( ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField, @@ -13,6 +13,7 @@ from utilities.templatetags.builtins.filters import bettertitle from utilities.forms.widgets import HTMXSelect __all__ = ( + 'FrontPortFormMixin', 'ScopedBulkEditForm', 'ScopedForm', 'ScopedImportForm', @@ -128,3 +129,51 @@ class ScopedImportForm(forms.Form): "Please select a {scope_type}." ).format(scope_type=scope_type.model_class()._meta.model_name) }) + + +class FrontPortFormMixin(forms.Form): + rear_ports = forms.MultipleChoiceField( + choices=[], + label=_('Rear ports'), + widget=forms.SelectMultiple(attrs={'size': 8}) + ) + + port_assignment_model = PortAssignmentTemplate + + def clean(self): + super().clean() + + # FrontPort with no positions cannot be mapped to more than one RearPort + if not self.cleaned_data['positions'] and len(self.cleaned_data['rear_ports']) > 1: + raise forms.ValidationError({ + 'positions': _("A front port with no positions cannot be mapped to multiple rear ports.") + }) + + # Count of selected rear port & position pairs much match the assigned number of positions + if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']: + raise forms.ValidationError({ + 'rear_ports': _( + "The number of rear port/position pairs selected must match the number of positions assigned." + ) + }) + + def _save_m2m(self): + super()._save_m2m() + + # TODO: Can this be made more efficient? + # Delete existing rear port assignments + self.port_assignment_model.objects.filter(front_port_id=self.instance.pk).delete() + + # Create new rear port assignments + assignments = [] + for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1): + rear_port_id, rear_port_position = rp_position.split(':') + assignments.append( + self.port_assignment_model( + front_port_id=self.instance.pk, + front_port_position=i, + rear_port_id=rear_port_id, + rear_port_position=rear_port_position, + ) + ) + self.port_assignment_model.objects.bulk_create(assignments) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 6d8c4ba42..b7d7351ab 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -6,6 +6,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * +from dcim.forms.mixins import FrontPortFormMixin from dcim.models import * from extras.models import ConfigTemplate from ipam.choices import VLANQinQRoleChoices @@ -1111,29 +1112,67 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): ] -class FrontPortTemplateForm(ModularComponentTemplateForm): - rear_ports = forms.MultipleChoiceField( - choices=[], - label=_('Rear ports'), - widget=forms.SelectMultiple(attrs={'size': 8}) - ) - +class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): fieldsets = ( FieldSet( TabbedGroups( FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description', + 'name', 'label', 'positions', 'rear_ports', 'description', ), ) + port_assignment_model = PortAssignmentTemplate + parent_field = 'device_type' + class Meta: model = FrontPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if device_type_id := self.data.get('device_type') or self.initial.get('device_type'): + device_type = DeviceType.objects.get(pk=device_type_id) + else: + return + + # Populate rear port choices + self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance) + + # Set initial rear port assignments + if self.instance.pk: + self.initial['rear_ports'] = [ + f'{assignment.rear_port_id}:{assignment.rear_port_position}' + for assignment in PortAssignmentTemplate.objects.filter(front_port_id=self.instance.pk) + ] + + def _get_rear_port_choices(self, device_type, front_port): + """ + Return a list of choices representing each available rear port & position pair on the device type, excluding + those assigned to the specified instance. + """ + occupied_rear_port_positions = [ + f'{assignment.rear_port_id}:{assignment.rear_port_position}' + for assignment in PortAssignmentTemplate.objects.filter( + front_port__device_type=device_type + ).exclude(front_port=front_port.pk) + ] + + choices = [] + for rear_port in RearPortTemplate.objects.filter(device_type=device_type): + for i in range(1, rear_port.positions + 1): + pair_id = f'{rear_port.pk}:{i}' + if pair_id not in occupied_rear_port_positions: + pair_label = f'{rear_port.name}:{i}' + choices.append( + (pair_id, pair_label) + ) + return choices + class RearPortTemplateForm(ModularComponentTemplateForm): fieldsets = ( @@ -1572,13 +1611,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): } -class FrontPortForm(ModularDeviceComponentForm): - rear_ports = forms.MultipleChoiceField( - choices=[], - label=_('Rear ports'), - widget=forms.SelectMultiple(attrs={'size': 8}) - ) - +class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): fieldsets = ( FieldSet( 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected', @@ -1586,6 +1619,8 @@ class FrontPortForm(ModularDeviceComponentForm): ), ) + port_assignment_model = PortAssignment + class Meta: model = FrontPort fields = [ @@ -1611,44 +1646,6 @@ class FrontPortForm(ModularDeviceComponentForm): for assignment in PortAssignment.objects.filter(front_port_id=self.instance.pk) ] - def clean(self): - super().clean() - - # FrontPort with no positions cannot be mapped to more than one RearPort - if not self.cleaned_data['positions'] and len(self.cleaned_data['rear_ports']) > 1: - raise forms.ValidationError({ - 'positions': _("A front port with no positions cannot be mapped to multiple rear ports.") - }) - - # Count of selected rear port & position pairs much match the assigned number of positions - if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']: - raise forms.ValidationError({ - 'rear_ports': _( - "The number of rear port/position pairs selected must match the number of positions assigned." - ) - }) - - def _save_m2m(self): - super()._save_m2m() - - # TODO: Can this be made more efficient? - # Delete existing rear port assignments - PortAssignment.objects.filter(front_port_id=self.instance.pk).delete() - - # Create new rear port assignments - assignments = [] - for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1): - rear_port_id, rear_port_position = rp_position.split(':') - assignments.append( - PortAssignment( - front_port_id=self.instance.pk, - front_port_position=i, - rear_port_id=rear_port_id, - rear_port_position=rear_port_position, - ) - ) - PortAssignment.objects.bulk_create(assignments) - def _get_rear_port_choices(self, device, front_port): """ Return a list of choices representing each available rear port & position pair on the device, excluding those diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 028ab6c3c..86e5be279 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -109,69 +109,37 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): - rear_port = forms.MultipleChoiceField( - choices=[], - label=_('Rear ports'), - help_text=_('Select one rear port assignment for each front port being created.'), - widget=forms.SelectMultiple(attrs={'size': 6}) - ) - # Override fieldsets from FrontPortTemplateForm to omit rear_port_position + # Override fieldsets from FrontPortTemplateForm fieldsets = ( FieldSet( TabbedGroups( FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'type', 'color', 'rear_port', 'description', + 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description', ), ) - class Meta(model_forms.FrontPortTemplateForm.Meta): - exclude = ('name', 'label', 'rear_port', 'rear_port_position') - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # TODO: This needs better validation - if 'device_type' in self.initial or self.data.get('device_type'): - parent = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - elif 'module_type' in self.initial or self.data.get('module_type'): - parent = ModuleType.objects.get( - pk=self.initial.get('module_type') or self.data.get('module_type') - ) - else: - return - - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in parent.frontporttemplates.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = parent.rearporttemplates.all() - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port'].choices = choices + class Meta: + model = FrontPortTemplate + fields = ( + 'device_type', 'module_type', 'type', 'color', 'positions', 'description', + ) def clean(self): - super().clean() + # TODO + # super(ComponentCreateForm, self).clean() # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate # positions + print(f"name: {self.cleaned_data['name']}") + print(f"rear_ports: {self.cleaned_data['rear_ports']}") frontport_count = len(self.cleaned_data['name']) - rearport_count = len(self.cleaned_data['rear_port']) + rearport_count = len(self.cleaned_data['rear_ports']) if frontport_count != rearport_count: raise forms.ValidationError({ - 'rear_port': _( + 'rear_ports': _( "The number of front port templates to be created ({frontport_count}) must match the selected " "number of rear port positions ({rearport_count})." ).format( @@ -181,13 +149,11 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp }) def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') + positions = self.cleaned_data['positions'] + offset = positions * iteration return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), + 'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions] } diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index dc3146161..317e77452 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField -from dcim.utils import update_interface_bridges +from dcim.utils import create_port_assignments, update_interface_bridges from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices @@ -30,6 +30,7 @@ from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField from utilities.prefetch import get_prefetchable_fields from utilities.tracking import TrackingModelMixin +from . import PortAssignmentTemplate from .device_components import * from .mixins import RenderConfigMixin from .modules import Module @@ -1008,6 +1009,10 @@ class Device( self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) # Interface bridges have to be set after interface instantiation update_interface_bridges(self, self.device_type.interfacetemplates.all()) + # Replicate any front/rear port assignments from the DeviceType + create_port_assignments(self, PortAssignmentTemplate.objects.filter( + front_port__device_type=self.device_type + )) # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 2380fbd0d..50963890f 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -83,3 +83,23 @@ def update_interface_bridges(device, interface_templates, module=None): ) interface.full_clean() interface.save() + + +def create_port_assignments(device, templates, module=None): + """ + Used for device and module instantiation. Replicate all front/rear port assignments from a DeviceType to the given + device. + """ + from dcim.models.device_components import FrontPort, PortAssignment, RearPort + + for template in templates: + front_port = FrontPort.objects.get(device=device, name=template.front_port.resolve_name(module=module)) + rear_port = RearPort.objects.get(device=device, name=template.rear_port.resolve_name(module=module)) + + assignment = PortAssignment( + front_port=front_port, + front_port_position=template.front_port_position, + rear_port=rear_port, + rear_port_position=template.rear_port_position, + ) + assignment.save() From b9d57c74ca88f22378f38328cfcb9abe95baaf61 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 08:35:25 -0500 Subject: [PATCH 26/51] Update migrations --- netbox/dcim/migrations/0221_m2m_port_assignments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index 7b29137d4..0af2016fc 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -24,7 +24,7 @@ def populate_port_template_assignments(apps, schema_editor): for front_port in front_ports: yield PortAssignmentTemplate( front_port_id=front_port.pk, - front_port_position=None, + front_port_position=1, rear_port_id=front_port.rear_port_id, rear_port_position=front_port.rear_port_position, ) @@ -44,7 +44,7 @@ def populate_port_assignments(apps, schema_editor): for front_port in front_ports: yield PortAssignment( front_port_id=front_port.pk, - front_port_position=None, + front_port_position=1, rear_port_id=front_port.rear_port_id, rear_port_position=front_port.rear_port_position, ) From 5b8d80a371981c4b13dacafc2c18134d102bc8d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 10:06:43 -0500 Subject: [PATCH 27/51] Fix filterset tests --- netbox/dcim/filtersets.py | 36 ++++--- .../migrations/0221_m2m_port_assignments.py | 4 + netbox/dcim/models/base.py | 2 + netbox/dcim/tests/test_filtersets.py | 102 ++++++++---------- 4 files changed, 70 insertions(+), 74 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4a7463fdd..5bb8cf06d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -884,10 +884,11 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=PortTypeChoices, null_value=None ) - # TODO - # rear_port_id = django_filters.ModelMultipleChoiceFilter( - # queryset=RearPortTemplate.objects.all() - # ) + rear_port_template_id = django_filters.ModelMultipleChoiceFilter( + field_name='rear_ports', + queryset=FrontPortTemplate.objects.all(), + label=_('Rear port template (ID)'), + ) class Meta: model = FrontPortTemplate @@ -899,10 +900,11 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom choices=PortTypeChoices, null_value=None ) - # TODO - # front_port_id = django_filters.ModelMultipleChoiceFilter( - # queryset=FrontPortTemplate.objects.all() - # ) + front_port_template_id = django_filters.ModelMultipleChoiceFilter( + field_name='front_ports', + queryset=FrontPortTemplate.objects.all(), + label=_('Front port template (ID)'), + ) class Meta: model = RearPortTemplate @@ -2106,10 +2108,11 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) choices=PortTypeChoices, null_value=None ) - # TODO - # rear_port_id = django_filters.ModelMultipleChoiceFilter( - # queryset=RearPort.objects.all() - # ) + rear_port_id = django_filters.ModelMultipleChoiceFilter( + field_name='rear_ports', + queryset=RearPort.objects.all(), + label=_('Rear port (ID)'), + ) class Meta: model = FrontPort @@ -2124,10 +2127,11 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): choices=PortTypeChoices, null_value=None ) - # TODO - # front_port_id = django_filters.ModelMultipleChoiceFilter( - # queryset=FrontPort.objects.all() - # ) + front_port_id = django_filters.ModelMultipleChoiceFilter( + field_name='front_ports', + queryset=FrontPort.objects.all(), + label=_('Front port (ID)'), + ) class Meta: model = RearPort diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index 0af2016fc..e5b00aa1d 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -68,6 +68,7 @@ class Migration(migrations.Migration): ( 'front_port_position', models.PositiveSmallIntegerField( + default=1, validators=[ django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024) @@ -77,6 +78,7 @@ class Migration(migrations.Migration): ( 'rear_port_position', models.PositiveSmallIntegerField( + default=1, validators=[ django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024) @@ -127,6 +129,7 @@ class Migration(migrations.Migration): ( 'front_port_position', models.PositiveSmallIntegerField( + default=1, validators=[ django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024) @@ -136,6 +139,7 @@ class Migration(migrations.Migration): ( 'rear_port_position', models.PositiveSmallIntegerField( + default=1, validators=[ django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024), diff --git a/netbox/dcim/models/base.py b/netbox/dcim/models/base.py index 41ef27131..0689f1ff0 100644 --- a/netbox/dcim/models/base.py +++ b/netbox/dcim/models/base.py @@ -15,12 +15,14 @@ class PortAssignmentBase(models.Model): Base class for PortAssignment and PortAssignment Template """ front_port_position = models.PositiveSmallIntegerField( + default=1, validators=( MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX), ), ) rear_port_position = models.PositiveSmallIntegerField( + default=1, validators=( MinValueValidator(PORT_POSITION_MIN), MaxValueValidator(PORT_POSITION_MAX), diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 0ba777204..1b296766e 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1355,22 +1355,15 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): RearPortTemplate(device_type=device_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_ports) - FrontPortTemplate.objects.bulk_create( - ( - FrontPortTemplate( - device_type=device_types[0], - name='Front Port 1', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_ports[0], - ), - FrontPortTemplate( - device_type=device_types[1], - name='Front Port 2', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_ports[1], - ), - ) + front_ports = ( + FrontPortTemplate(device_type=device_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), ) + FrontPortTemplate.objects.bulk_create(front_ports) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + ]) ModuleBayTemplate.objects.bulk_create(( ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'), ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'), @@ -1626,22 +1619,15 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): RearPortTemplate(module_type=module_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_ports) - FrontPortTemplate.objects.bulk_create( - ( - FrontPortTemplate( - module_type=module_types[0], - name='Front Port 1', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_ports[0], - ), - FrontPortTemplate( - module_type=module_types[1], - name='Front Port 2', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_ports[1], - ), - ) + front_ports = ( + FrontPortTemplate(module_type=module_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=module_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), ) + FrontPortTemplate.objects.bulk_create(front_ports) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + ]) def test_q(self): params = {'q': 'foobar1'} @@ -2057,11 +2043,10 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ) RearPortTemplate.objects.bulk_create(rear_ports) - FrontPortTemplate.objects.bulk_create(( + front_ports = ( FrontPortTemplate( device_type=device_types[0], name='Front Port 1', - rear_port=rear_ports[0], type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, description='foobar1' @@ -2069,7 +2054,6 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, FrontPortTemplate( device_type=device_types[1], name='Front Port 2', - rear_port=rear_ports[1], type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, description='foobar2' @@ -2077,12 +2061,17 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, FrontPortTemplate( device_type=device_types[2], name='Front Port 3', - rear_port=rear_ports[2], type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, description='foobar3' ), - )) + ) + FrontPortTemplate.objects.bulk_create(front_ports) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + ]) def test_name(self): params = {'name': ['Front Port 1', 'Front Port 2']} @@ -2752,10 +2741,15 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C), ) RearPort.objects.bulk_create(rear_ports) - FrontPort.objects.bulk_create(( - FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), - FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), - )) + front_ports = ( + FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), + ) + FrontPort.objects.bulk_create(front_ports) + PortAssignment.objects.bulk_create([ + PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), + ]) ModuleBay.objects.create(device=devices[0], name='Module Bay 1') ModuleBay.objects.create(device=devices[1], name='Module Bay 2') DeviceBay.objects.bulk_create(( @@ -5090,8 +5084,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil label='A', type=PortTypeChoices.TYPE_8P8C, color=ColorChoices.COLOR_RED, - rear_port=rear_ports[0], - rear_port_position=1, description='First', _site=devices[0].site, _location=devices[0].location, @@ -5104,8 +5096,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil label='B', type=PortTypeChoices.TYPE_110_PUNCH, color=ColorChoices.COLOR_GREEN, - rear_port=rear_ports[1], - rear_port_position=2, description='Second', _site=devices[1].site, _location=devices[1].location, @@ -5118,8 +5108,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil label='C', type=PortTypeChoices.TYPE_BNC, color=ColorChoices.COLOR_BLUE, - rear_port=rear_ports[2], - rear_port_position=3, description='Third', _site=devices[2].site, _location=devices[2].location, @@ -5130,8 +5118,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, - rear_port=rear_ports[3], - rear_port_position=1, _site=devices[3].site, _location=devices[3].location, _rack=devices[3].rack, @@ -5141,8 +5127,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, - rear_port=rear_ports[4], - rear_port_position=1, _site=devices[3].site, _location=devices[3].location, _rack=devices[3].rack, @@ -5152,14 +5136,20 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, - rear_port=rear_ports[5], - rear_port_position=1, _site=devices[3].site, _location=devices[3].location, _rack=devices[3].rack, ), ) FrontPort.objects.bulk_create(front_ports) + PortAssignment.objects.bulk_create([ + PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1], rear_port_position=2), + PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2], rear_port_position=3), + PortAssignment(front_port=front_ports[3], rear_port=rear_ports[3]), + PortAssignment(front_port=front_ports[4], rear_port=rear_ports[4]), + PortAssignment(front_port=front_ports[5], rear_port=rear_ports[5]), + ]) # Cables Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save() @@ -6420,13 +6410,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') power_port = PowerPort.objects.create(device=devices[0], name='Power Port 1') power_outlet = PowerOutlet.objects.create(device=devices[0], name='Power Outlet 1') - rear_port = RearPort.objects.create(device=devices[0], name='Rear Port 1', positions=1) - front_port = FrontPort.objects.create( - device=devices[0], - name='Front Port 1', - rear_port=rear_port, - rear_port_position=1 - ) + rear_port = RearPort.objects.create(device=devices[0], name='Rear Port 1') + front_port = FrontPort.objects.create(device=devices[0], name='Front Port 1') + PortAssignment.objects.create(front_port=front_port, rear_port=rear_port) power_panel = PowerPanel.objects.create(name='Power Panel 1', site=sites[0]) power_feed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel) From bfff2d76586e20d78a9cc3ba155944c6891a4291 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 12:41:06 -0500 Subject: [PATCH 28/51] Update API tests --- .../api/serializers_/device_components.py | 87 ++++++++++++++++--- netbox/dcim/graphql/types.py | 8 +- .../migrations/0221_m2m_port_assignments.py | 30 ++++++- .../dcim/models/device_component_templates.py | 2 + netbox/dcim/models/device_components.py | 4 +- netbox/dcim/tests/test_api.py | 84 +++++++++--------- 6 files changed, 152 insertions(+), 63 deletions(-) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 22fe4777c..49fe2bc46 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -5,15 +5,15 @@ from rest_framework import serializers from dcim.choices import * from dcim.constants import * from dcim.models import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, - RearPort, VirtualDeviceContext, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortAssignment, + PowerOutlet, PowerPort, RearPort, VirtualDeviceContext, ) from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer from ipam.api.serializers_.vrfs import VRFSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.gfk_fields import GFKSerializerField -from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer +from netbox.api.serializers import NetBoxModelSerializer from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer from wireless.api.serializers_.nested import NestedWirelessLinkSerializer from wireless.api.serializers_.wirelesslans import WirelessLANSerializer @@ -294,6 +294,16 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect return super().validate(data) +class RearPortAssignmentSerializer(serializers.ModelSerializer): + front_port = serializers.PrimaryKeyRelatedField( + queryset=FrontPort.objects.all(), + ) + + class Meta: + model = PortAssignment + fields = ('id', 'rear_port_position', 'front_port', 'front_port_position') + + class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): device = DeviceSerializer(nested=True) module = ModuleSerializer( @@ -303,25 +313,52 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): allow_null=True ) type = ChoiceField(choices=PortTypeChoices) + front_ports = RearPortAssignmentSerializer( + source='assignments', + many=True, + required=False, + ) class Meta: model = RearPort fields = [ 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', - 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', - 'custom_fields', 'created', 'last_updated', '_occupied', + 'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + def create(self, validated_data): + assignments = validated_data.pop('assignments', []) + instance = super().create(validated_data) -class FrontPortRearPortSerializer(WritableNestedSerializer): - """ - NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device) - """ + # Create FrontPort assignments + for assignment_data in assignments: + PortAssignment.objects.create(rear_port=instance, **assignment_data) + + return instance + + def update(self, instance, validated_data): + assignments = validated_data.pop('assignments', None) + instance = super().update(instance, validated_data) + + if assignments is not None: + # Update FrontPort assignments + PortAssignment.objects.filter(rear_port=instance).delete() + for assignment_data in assignments: + PortAssignment.objects.create(rear_port=instance, **assignment_data) + + return instance + + +class FrontPortAssignmentSerializer(serializers.ModelSerializer): + rear_port = serializers.PrimaryKeyRelatedField( + queryset=RearPort.objects.all(), + ) class Meta: - model = RearPort - fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description'] + model = PortAssignment + fields = ('id', 'front_port_position', 'rear_port', 'rear_port_position') class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): @@ -333,7 +370,11 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): allow_null=True ) type = ChoiceField(choices=PortTypeChoices) - rear_ports = FrontPortRearPortSerializer(many=True) + rear_ports = FrontPortAssignmentSerializer( + source='assignments', + many=True, + required=False, + ) class Meta: model = FrontPort @@ -344,6 +385,28 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') + def create(self, validated_data): + assignments = validated_data.pop('assignments', []) + instance = super().create(validated_data) + + # Create RearPort assignments + for assignment_data in assignments: + PortAssignment.objects.create(front_port=instance, **assignment_data) + + return instance + + def update(self, instance, validated_data): + assignments = validated_data.pop('assignments', None) + instance = super().update(instance, validated_data) + + if assignments is not None: + # Update RearPort assignments + PortAssignment.objects.filter(front_port=instance).delete() + for assignment_data in assignments: + PortAssignment.objects.create(front_port=instance, **assignment_data) + + return instance + class ModuleBaySerializer(NetBoxModelSerializer): device = DeviceSerializer(nested=True) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 13408dc90..ca7fc3172 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -385,7 +385,7 @@ class DeviceTypeType(PrimaryObjectType): ) class FrontPortType(ModularComponentType, CabledObjectMixin): color: str - rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] + # rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] @strawberry_django.type( @@ -396,7 +396,7 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): ) class FrontPortTemplateType(ModularComponentTemplateType): color: str - rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] + # rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] @strawberry_django.type( @@ -768,7 +768,7 @@ class RackRoleType(OrganizationalObjectType): class RearPortType(ModularComponentType, CabledObjectMixin): color: str - frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] + front_ports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -780,7 +780,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin): class RearPortTemplateType(ModularComponentTemplateType): color: str - frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + front_ports: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index e5b00aa1d..c0e78d62a 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -87,11 +87,19 @@ class Migration(migrations.Migration): ), ( 'front_port', - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.frontporttemplate') + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='dcim.frontporttemplate', + related_name='assignments' + ) ), ( 'rear_port', - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.rearporttemplate') + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='dcim.rearporttemplate', + related_name='assignments' + ) ), ], ), @@ -146,8 +154,22 @@ class Migration(migrations.Migration): ] ), ), - ('front_port', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.frontport')), - ('rear_port', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dcim.rearport')), + ( + 'front_port', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='dcim.frontport', + related_name='assignments' + ) + ), + ( + 'rear_port', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='dcim.rearport', + related_name='assignments' + ) + ), ], ), migrations.AddConstraint( diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 6a6e967cf..c083204ba 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -527,10 +527,12 @@ class PortAssignmentTemplate(PortAssignmentBase): front_port = models.ForeignKey( to='dcim.FrontPortTemplate', on_delete=models.CASCADE, + related_name='assignments', ) rear_port = models.ForeignKey( to='dcim.RearPortTemplate', on_delete=models.CASCADE, + related_name='assignments', ) def clean(self): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index be340fde3..fdd75b274 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1078,10 +1078,12 @@ class PortAssignment(PortAssignmentBase): front_port = models.ForeignKey( to='dcim.FrontPort', on_delete=models.CASCADE, + related_name='assignments', ) rear_port = models.ForeignKey( to='dcim.RearPort', on_delete=models.CASCADE, + related_name='assignments', ) def clean(self): @@ -1168,7 +1170,7 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): # Check that positions count is greater than or equal to the number of associated FrontPorts if not self._state.adding: - frontport_count = self.frontports.count() + frontport_count = self.front_ports.count() if self.positions < frontport_count: raise ValidationError({ "positions": _( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ea9f7a84a..1388a24ff 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -981,32 +981,18 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): RearPortTemplate.objects.bulk_create(rear_port_templates) front_port_templates = ( - FrontPortTemplate( - device_type=devicetype, - name='Front Port Template 1', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port_templates[0] - ), - FrontPortTemplate( - device_type=devicetype, - name='Front Port Template 2', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port_templates[1] - ), - FrontPortTemplate( - module_type=moduletype, - name='Front Port Template 5', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port_templates[4] - ), - FrontPortTemplate( - module_type=moduletype, - name='Front Port Template 6', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port_templates[5] - ), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=moduletype, name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=moduletype, name='Front Port Template 6', type=PortTypeChoices.TYPE_8P8C), ) FrontPortTemplate.objects.bulk_create(front_port_templates) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), + PortAssignmentTemplate(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), + PortAssignmentTemplate(front_port=front_port_templates[2], rear_port=rear_port_templates[4]), + PortAssignmentTemplate(front_port=front_port_templates[3], rear_port=rear_port_templates[5]), + ]) cls.create_data = [ { @@ -2017,49 +2003,63 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): RearPort.objects.bulk_create(rear_ports) front_ports = ( - FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), - FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), - FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2]), + FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C), ) FrontPort.objects.bulk_create(front_ports) + PortAssignment.objects.bulk_create([ + PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), + ]) cls.create_data = [ { 'device': device.pk, 'name': 'Front Port 4', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_ports[3].pk, - 'rear_port_position': 1, + 'rear_ports': [ + { + 'front_port_position': 1, + 'rear_port': rear_ports[3].pk, + 'rear_port_position': 1, + }, + ], }, { 'device': device.pk, 'name': 'Front Port 5', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_ports[4].pk, - 'rear_port_position': 1, + 'rear_ports': [ + { + 'front_port_position': 1, + 'rear_port': rear_ports[4].pk, + 'rear_port_position': 1, + }, + ], }, { 'device': device.pk, 'name': 'Front Port 6', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_ports[5].pk, - 'rear_port_position': 1, + 'rear_ports': [ + { + 'front_port_position': 1, + 'rear_port': rear_ports[5].pk, + 'rear_port_position': 1, + }, + ], }, ] @tag('regression') # Issue #18991 def test_front_port_paths(self): device = Device.objects.first() - rear_port = RearPort.objects.create( - device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C - ) interface1 = Interface.objects.create(device=device, name='Interface 1') - front_port = FrontPort.objects.create( - device=device, - name='Rear Port 10', - type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port, - ) + rear_port = RearPort.objects.create(device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C) + front_port = FrontPort.objects.create(device=device, name='Front Port 10', type=PortTypeChoices.TYPE_8P8C) + PortAssignment.objects.create(front_port=front_port, rear_port=rear_port) Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port]) self.add_permissions(f'dcim.view_{self.model._meta.model_name}') From 66bbfa7a8820c51ade6d930eea70105c5fa423be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 13:15:57 -0500 Subject: [PATCH 29/51] Remove rear_ports M2M fields from FrontPort & FrontPortTemplate --- .../api/serializers_/devicetype_components.py | 82 ++++++++++++++++++- netbox/dcim/filtersets.py | 24 +++--- netbox/dcim/forms/object_create.py | 2 - netbox/dcim/graphql/types.py | 32 +++++++- .../migrations/0221_m2m_port_assignments.py | 22 ----- .../dcim/models/device_component_templates.py | 5 -- netbox/dcim/models/device_components.py | 16 ++-- netbox/dcim/signals.py | 4 +- netbox/dcim/tests/test_api.py | 36 ++++++-- 9 files changed, 156 insertions(+), 67 deletions(-) diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index ed2893ed4..b3ee95ad3 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -5,7 +5,8 @@ from dcim.choices import * from dcim.constants import * from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, - InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, + InventoryItemTemplate, ModuleBayTemplate, PortAssignmentTemplate, PowerOutletTemplate, PowerPortTemplate, + RearPortTemplate, ) from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.gfk_fields import GFKSerializerField @@ -205,6 +206,16 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') +class RearPortTemplateAssignmentSerializer(serializers.ModelSerializer): + front_port = serializers.PrimaryKeyRelatedField( + queryset=FrontPortTemplate.objects.all(), + ) + + class Meta: + model = PortAssignmentTemplate + fields = ('id', 'rear_port_position', 'front_port', 'front_port_position') + + class RearPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( required=False, @@ -219,15 +230,52 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer): default=None ) type = ChoiceField(choices=PortTypeChoices) + front_ports = RearPortTemplateAssignmentSerializer( + source='assignments', + many=True, + required=False, + ) class Meta: model = RearPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', - 'positions', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', + 'front_ports', 'description', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') + def create(self, validated_data): + assignments = validated_data.pop('assignments', []) + instance = super().create(validated_data) + + # Create FrontPort assignments + for assignment_data in assignments: + PortAssignmentTemplate.objects.create(rear_port=instance, **assignment_data) + + return instance + + def update(self, instance, validated_data): + assignments = validated_data.pop('assignments', None) + instance = super().update(instance, validated_data) + + if assignments is not None: + # Update FrontPort assignments + PortAssignmentTemplate.objects.filter(rear_port=instance).delete() + for assignment_data in assignments: + PortAssignmentTemplate.objects.create(rear_port=instance, **assignment_data) + + return instance + + +class FrontPortTemplateAssignmentSerializer(serializers.ModelSerializer): + rear_port = serializers.PrimaryKeyRelatedField( + queryset=RearPortTemplate.objects.all(), + ) + + class Meta: + model = PortAssignmentTemplate + fields = ('id', 'front_port_position', 'rear_port', 'rear_port_position') + class FrontPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( @@ -243,7 +291,11 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer): default=None ) type = ChoiceField(choices=PortTypeChoices) - rear_ports = RearPortTemplateSerializer(nested=True, many=True) + rear_ports = FrontPortTemplateAssignmentSerializer( + source='assignments', + many=True, + required=False, + ) class Meta: model = FrontPortTemplate @@ -253,6 +305,28 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer): ] brief_fields = ('id', 'url', 'display', 'name', 'description') + def create(self, validated_data): + assignments = validated_data.pop('assignments', []) + instance = super().create(validated_data) + + # Create RearPort assignments + for assignment_data in assignments: + PortAssignmentTemplate.objects.create(front_port=instance, **assignment_data) + + return instance + + def update(self, instance, validated_data): + assignments = validated_data.pop('assignments', None) + instance = super().update(instance, validated_data) + + if assignments is not None: + # Update RearPort assignments + PortAssignmentTemplate.objects.filter(front_port=instance).delete() + for assignment_data in assignments: + PortAssignmentTemplate.objects.create(front_port=instance, **assignment_data) + + return instance + class ModuleBayTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5bb8cf06d..47ec1a0c6 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -884,10 +884,11 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=PortTypeChoices, null_value=None ) - rear_port_template_id = django_filters.ModelMultipleChoiceFilter( - field_name='rear_ports', - queryset=FrontPortTemplate.objects.all(), - label=_('Rear port template (ID)'), + rear_port_id = django_filters.ModelMultipleChoiceFilter( + field_name='assignments__rear_port', + queryset=RearPort.objects.all(), + to_field_name='rear_port', + label=_('Rear port (ID)'), ) class Meta: @@ -900,10 +901,11 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom choices=PortTypeChoices, null_value=None ) - front_port_template_id = django_filters.ModelMultipleChoiceFilter( - field_name='front_ports', - queryset=FrontPortTemplate.objects.all(), - label=_('Front port template (ID)'), + front_port_id = django_filters.ModelMultipleChoiceFilter( + field_name='assignments__front_port', + queryset=FrontPort.objects.all(), + to_field_name='front_port', + label=_('Front port (ID)'), ) class Meta: @@ -2109,8 +2111,9 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) null_value=None ) rear_port_id = django_filters.ModelMultipleChoiceFilter( - field_name='rear_ports', + field_name='assignments__rear_port', queryset=RearPort.objects.all(), + to_field_name='rear_port', label=_('Rear port (ID)'), ) @@ -2128,8 +2131,9 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): null_value=None ) front_port_id = django_filters.ModelMultipleChoiceFilter( - field_name='front_ports', + field_name='assignments__front_port', queryset=FrontPort.objects.all(), + to_field_name='front_port', label=_('Front port (ID)'), ) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 86e5be279..6b34ecc24 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -133,8 +133,6 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate # positions - print(f"name: {self.cleaned_data['name']}") - print(f"rear_ports: {self.cleaned_data['rear_ports']}") frontport_count = len(self.cleaned_data['name']) rearport_count = len(self.cleaned_data['rear_ports']) if frontport_count != rearport_count: diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index ca7fc3172..d9e5cd4d3 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -385,7 +385,8 @@ class DeviceTypeType(PrimaryObjectType): ) class FrontPortType(ModularComponentType, CabledObjectMixin): color: str - # rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] + + assignments: List[Annotated["PortAssignmentType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -396,7 +397,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): ) class FrontPortTemplateType(ModularComponentTemplateType): color: str - # rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] + + assignments: List[Annotated["PortAssignmentTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -636,6 +638,28 @@ class PlatformType(NestedGroupObjectType): devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.PortAssignment, + fields='__all__', + # filters=PortAssignmentFilter, + pagination=True +) +class PortAssignmentType(ModularComponentTemplateType): + front_port: Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')] + rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] + + +@strawberry_django.type( + models.PortAssignmentTemplate, + fields='__all__', + # filters=PortAssignmentTemplateFilter, + pagination=True +) +class PortAssignmentTemplateType(ModularComponentTemplateType): + front_port_template: Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')] + rear_port_template: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] + + @strawberry_django.type( models.PowerFeed, exclude=['_path'], @@ -768,7 +792,7 @@ class RackRoleType(OrganizationalObjectType): class RearPortType(ModularComponentType, CabledObjectMixin): color: str - front_ports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] + assignments: List[Annotated["PortAssignmentType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -780,7 +804,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin): class RearPortTemplateType(ModularComponentTemplateType): color: str - front_ports: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + assignments: List[Annotated["PortAssignmentTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( diff --git a/netbox/dcim/migrations/0221_m2m_port_assignments.py b/netbox/dcim/migrations/0221_m2m_port_assignments.py index c0e78d62a..580479897 100644 --- a/netbox/dcim/migrations/0221_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0221_m2m_port_assignments.py @@ -118,17 +118,6 @@ class Migration(migrations.Migration): ), ), - # Add rear_ports ManyToManyField on FrontPortTemplate - migrations.AddField( - model_name='frontporttemplate', - name='rear_ports', - field=models.ManyToManyField( - related_name='front_ports', - through='dcim.PortAssignmentTemplate', - to='dcim.rearporttemplate' - ), - ), - # Create PortAssignment model (for Devices) migrations.CreateModel( name='PortAssignment', @@ -187,17 +176,6 @@ class Migration(migrations.Migration): ), ), - # Add rear_ports ManyToManyField on FrontPort - migrations.AddField( - model_name='frontport', - name='rear_ports', - field=models.ManyToManyField( - related_name='front_ports', - through='dcim.PortAssignment', - to='dcim.rearport' - ), - ), - # Data migration migrations.RunPython( code=populate_port_template_assignments, diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index c083204ba..b30815889 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -568,11 +568,6 @@ class FrontPortTemplate(ModularComponentTemplateModel): MaxValueValidator(PORT_POSITION_MAX) ], ) - rear_ports = models.ManyToManyField( - to='dcim.RearPortTemplate', - through='dcim.PortAssignmentTemplate', - related_name='front_ports', - ) component_model = FrontPort diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index fdd75b274..e77ba2db2 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1119,13 +1119,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): MaxValueValidator(PORT_POSITION_MAX) ], ) - rear_ports = models.ManyToManyField( - to='dcim.RearPort', - through='dcim.PortAssignment', - related_name='front_ports', - ) - clone_fields = ('device', 'type', 'color') + clone_fields = ('device', 'type', 'color', 'positions') class Meta(ModularComponentModel.Meta): constraints = ( @@ -1159,6 +1154,7 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): MaxValueValidator(PORT_POSITION_MAX) ], ) + clone_fields = ('device', 'type', 'color', 'positions') class Meta(ModularComponentModel.Meta): @@ -1170,13 +1166,13 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): # Check that positions count is greater than or equal to the number of associated FrontPorts if not self._state.adding: - frontport_count = self.front_ports.count() - if self.positions < frontport_count: + assignment_count = self.assignments.count() + if self.positions < assignment_count: raise ValidationError({ "positions": _( "The number of positions cannot be less than the number of mapped front ports " - "({frontport_count})" - ).format(frontport_count=frontport_count) + "({count})" + ).format(count=assignment_count) }) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 636d7f484..10daa2ee0 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -156,8 +156,8 @@ def extend_rearport_cable_paths(instance, created, raw, **kwargs): When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort. """ if created and not raw: - for rear_port in instance.rear_ports.all(): - for cablepath in CablePath.objects.filter(_nodes__contains=rear_port): + for assignment in instance.assignments.prefetch_related('rear_port'): + for cablepath in CablePath.objects.filter(_nodes__contains=assignment.rear_port): cablepath.retrace() diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1388a24ff..a9657fa83 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -999,29 +999,49 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): 'device_type': devicetype.pk, 'name': 'Front Port Template 3', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[2].pk, - 'rear_port_position': 1, + 'rear_ports': [ + { + 'front_port_position': 1, + 'rear_port': rear_port_templates[2].pk, + 'rear_port_position': 1, + }, + ], }, { 'device_type': devicetype.pk, 'name': 'Front Port Template 4', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[3].pk, - 'rear_port_position': 1, + 'rear_ports': [ + { + 'front_port_position': 1, + 'rear_port': rear_port_templates[3].pk, + 'rear_port_position': 1, + }, + ], }, { 'module_type': moduletype.pk, 'name': 'Front Port Template 7', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[6].pk, - 'rear_port_position': 1, + 'rear_ports': [ + { + 'front_port_position': 1, + 'rear_port': rear_port_templates[6].pk, + 'rear_port_position': 1, + }, + ], }, { 'module_type': moduletype.pk, 'name': 'Front Port Template 8', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[7].pk, - 'rear_port_position': 1, + 'rear_ports': [ + { + 'front_port_position': 1, + 'rear_port': rear_port_templates[7].pk, + 'rear_port_position': 1, + }, + ], }, ] From 85d4066501845ec4f2fdaec8f20119c97c816816 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 13:41:25 -0500 Subject: [PATCH 30/51] Simplify nested port assignment representation --- .../api/serializers_/device_components.py | 10 +- .../api/serializers_/devicetype_components.py | 10 +- netbox/dcim/tests/test_api.py | 114 ++++++++++++------ 3 files changed, 94 insertions(+), 40 deletions(-) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 49fe2bc46..454977839 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -295,13 +295,16 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect class RearPortAssignmentSerializer(serializers.ModelSerializer): + position = serializers.IntegerField( + source='rear_port_position' + ) front_port = serializers.PrimaryKeyRelatedField( queryset=FrontPort.objects.all(), ) class Meta: model = PortAssignment - fields = ('id', 'rear_port_position', 'front_port', 'front_port_position') + fields = ('position', 'front_port', 'front_port_position') class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): @@ -352,13 +355,16 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): class FrontPortAssignmentSerializer(serializers.ModelSerializer): + position = serializers.IntegerField( + source='front_port_position' + ) rear_port = serializers.PrimaryKeyRelatedField( queryset=RearPort.objects.all(), ) class Meta: model = PortAssignment - fields = ('id', 'front_port_position', 'rear_port', 'rear_port_position') + fields = ('position', 'rear_port', 'rear_port_position') class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index b3ee95ad3..7f818fcd4 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -207,13 +207,16 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer): class RearPortTemplateAssignmentSerializer(serializers.ModelSerializer): + position = serializers.IntegerField( + source='rear_port_position' + ) front_port = serializers.PrimaryKeyRelatedField( queryset=FrontPortTemplate.objects.all(), ) class Meta: model = PortAssignmentTemplate - fields = ('id', 'rear_port_position', 'front_port', 'front_port_position') + fields = ('position', 'front_port', 'front_port_position') class RearPortTemplateSerializer(ComponentTemplateSerializer): @@ -268,13 +271,16 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer): class FrontPortTemplateAssignmentSerializer(serializers.ModelSerializer): + position = serializers.IntegerField( + source='front_port_position' + ) rear_port = serializers.PrimaryKeyRelatedField( queryset=RearPortTemplate.objects.all(), ) class Meta: model = PortAssignmentTemplate - fields = ('id', 'front_port_position', 'rear_port', 'rear_port_position') + fields = ('position', 'rear_port', 'rear_port_position') class FrontPortTemplateSerializer(ComponentTemplateSerializer): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a9657fa83..4bfd3c8e3 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -973,25 +973,20 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_port_templates) - front_port_templates = ( FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', type=PortTypeChoices.TYPE_8P8C), FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', type=PortTypeChoices.TYPE_8P8C), - FrontPortTemplate(module_type=moduletype, name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C), - FrontPortTemplate(module_type=moduletype, name='Front Port Template 6', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=moduletype, name='Front Port Template 3', type=PortTypeChoices.TYPE_8P8C), ) FrontPortTemplate.objects.bulk_create(front_port_templates) PortAssignmentTemplate.objects.bulk_create([ PortAssignmentTemplate(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), PortAssignmentTemplate(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), - PortAssignmentTemplate(front_port=front_port_templates[2], rear_port=rear_port_templates[4]), - PortAssignmentTemplate(front_port=front_port_templates[3], rear_port=rear_port_templates[5]), + PortAssignmentTemplate(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), ]) cls.create_data = [ @@ -1001,8 +996,8 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'rear_ports': [ { - 'front_port_position': 1, - 'rear_port': rear_port_templates[2].pk, + 'position': 1, + 'rear_port': rear_port_templates[3].pk, 'rear_port_position': 1, }, ], @@ -1013,8 +1008,8 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'rear_ports': [ { - 'front_port_position': 1, - 'rear_port': rear_port_templates[3].pk, + 'position': 1, + 'rear_port': rear_port_templates[4].pk, 'rear_port_position': 1, }, ], @@ -1025,20 +1020,8 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'rear_ports': [ { - 'front_port_position': 1, - 'rear_port': rear_port_templates[6].pk, - 'rear_port_position': 1, - }, - ], - }, - { - 'module_type': moduletype.pk, - 'name': 'Front Port Template 8', - 'type': PortTypeChoices.TYPE_8P8C, - 'rear_ports': [ - { - 'front_port_position': 1, - 'rear_port': rear_port_templates[7].pk, + 'position': 1, + 'rear_port': rear_port_templates[5].pk, 'rear_port_position': 1, }, ], @@ -1063,33 +1046,63 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): manufacturer=manufacturer, model='Module Type 1' ) + front_port_templates = ( + FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=moduletype, name='Front Port Template 3', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=moduletype, name='Front Port Template 4', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=moduletype, name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C), + FrontPortTemplate(module_type=moduletype, name='Front Port Template 6', type=PortTypeChoices.TYPE_8P8C), + ) + FrontPortTemplate.objects.bulk_create(front_port_templates) rear_port_templates = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_port_templates) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), + PortAssignmentTemplate(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), + PortAssignmentTemplate(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), + ]) cls.create_data = [ { 'device_type': devicetype.pk, 'name': 'Rear Port Template 4', 'type': PortTypeChoices.TYPE_8P8C, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_port_templates[3].pk, + 'front_port_position': 1, + }, + ], }, { 'device_type': devicetype.pk, 'name': 'Rear Port Template 5', 'type': PortTypeChoices.TYPE_8P8C, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_port_templates[4].pk, + 'front_port_position': 1, + }, + ], }, { 'module_type': moduletype.pk, 'name': 'Rear Port Template 6', 'type': PortTypeChoices.TYPE_8P8C, - }, - { - 'module_type': moduletype.pk, - 'name': 'Rear Port Template 7', - 'type': PortTypeChoices.TYPE_8P8C, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_port_templates[5].pk, + 'front_port_position': 1, + }, + ], }, ] @@ -2021,7 +2034,6 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): RearPort(device=device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C), ) RearPort.objects.bulk_create(rear_ports) - front_ports = ( FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C), FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), @@ -2041,7 +2053,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'rear_ports': [ { - 'front_port_position': 1, + 'position': 1, 'rear_port': rear_ports[3].pk, 'rear_port_position': 1, }, @@ -2053,7 +2065,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'rear_ports': [ { - 'front_port_position': 1, + 'position': 1, 'rear_port': rear_ports[4].pk, 'rear_port_position': 1, }, @@ -2065,7 +2077,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, 'rear_ports': [ { - 'front_port_position': 1, + 'position': 1, 'rear_port': rear_ports[5].pk, 'rear_port_position': 1, }, @@ -2106,6 +2118,15 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site) + front_ports = ( + FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=device, name='Front Port 4', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=device, name='Front Port 5', type=PortTypeChoices.TYPE_8P8C), + FrontPort(device=device, name='Front Port 6', type=PortTypeChoices.TYPE_8P8C), + ) + FrontPort.objects.bulk_create(front_ports) rear_ports = ( RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C), RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C), @@ -2118,16 +2139,37 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): 'device': device.pk, 'name': 'Rear Port 4', 'type': PortTypeChoices.TYPE_8P8C, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_ports[3].pk, + 'front_port_position': 1, + }, + ], }, { 'device': device.pk, 'name': 'Rear Port 5', 'type': PortTypeChoices.TYPE_8P8C, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_ports[4].pk, + 'front_port_position': 1, + }, + ], }, { 'device': device.pk, 'name': 'Rear Port 6', 'type': PortTypeChoices.TYPE_8P8C, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_ports[5].pk, + 'front_port_position': 1, + }, + ], }, ] From 62620101db3ac827c7440ac832764ad0089a3faa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 14:03:16 -0500 Subject: [PATCH 31/51] UI cleanup for front/rear ports --- netbox/dcim/forms/model_forms.py | 2 +- netbox/dcim/forms/object_create.py | 8 ++-- .../dcim/models/device_component_templates.py | 14 +++++++ netbox/dcim/tables/devices.py | 40 ++++++++++--------- netbox/dcim/tables/devicetypes.py | 15 ++++--- 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index b7d7351ab..052048de7 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1119,7 +1119,7 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'positions', 'rear_ports', 'description', + 'name', 'label', 'type', 'positions', 'rear_ports', 'description', ), ) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 6b34ecc24..69678e185 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -133,9 +133,10 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate # positions + positions = self.cleaned_data['positions'] frontport_count = len(self.cleaned_data['name']) rearport_count = len(self.cleaned_data['rear_ports']) - if frontport_count != rearport_count: + if frontport_count * positions != rearport_count: raise forms.ValidationError({ 'rear_ports': _( "The number of front port templates to be created ({frontport_count}) must match the selected " @@ -251,10 +252,11 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): def clean(self): super(NetBoxModelForm, self).clean() - # Check that the number of FrontPorts to be created matches the selected number of RearPort positions + # Check that the number of FrontPorts to be created matches the selected number of RearPorts + positions = self.cleaned_data['positions'] frontport_count = len(self.cleaned_data['name']) rearport_count = len(self.cleaned_data['rear_ports']) - if frontport_count != rearport_count: + if frontport_count * positions != rearport_count: raise forms.ValidationError({ 'rear_ports': _( "The number of front ports to be created ({frontport_count}) must match the selected number of " diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b30815889..d58e4ee44 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -635,6 +635,20 @@ class RearPortTemplate(ModularComponentTemplateModel): verbose_name = _('rear port template') verbose_name_plural = _('rear port templates') + def clean(self): + super().clean() + + # Check that positions count is greater than or equal to the number of associated FrontPortTemplates + if not self._state.adding: + assignment_count = self.assignments.count() + if self.positions < assignment_count: + raise ValidationError({ + "positions": _( + "The number of positions cannot be less than the number of mapped front port templates " + "({count})" + ).format(count=assignment_count) + }) + def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index cfdeb14ce..d24987265 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -749,12 +749,9 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): color = columns.ColorColumn( verbose_name=_('Color'), ) - rear_port_position = tables.Column( - verbose_name=_('Position') - ) - rear_port = tables.Column( - verbose_name=_('Rear Port'), - linkify=True + assignments = columns.ManyToManyColumn( + verbose_name=_('Assignments'), + transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}' ) tags = columns.TagColumn( url_name='dcim:frontport_list' @@ -763,12 +760,12 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = models.FrontPort fields = ( - 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', - 'inventory_items', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', + 'last_updated', ) default_columns = ( - 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', + 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'assignments', 'description', ) @@ -786,11 +783,11 @@ class DeviceFrontPortTable(FrontPortTable): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta): model = models.FrontPort fields = ( - 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', + 'pk', 'name', 'label', 'type', 'color', 'positions', 'assignments', 'description', 'cable', 'link_peer', ) @@ -805,6 +802,10 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): color = columns.ColorColumn( verbose_name=_('Color'), ) + assignments = columns.ManyToManyColumn( + verbose_name=_('Assignments'), + transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}' + ) tags = columns.TagColumn( url_name='dcim:rearport_list' ) @@ -812,10 +813,13 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = models.RearPort fields = ( - 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', + 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'assignments', 'description', ) - default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') class DeviceRearPortTable(RearPortTable): @@ -832,11 +836,11 @@ class DeviceRearPortTable(RearPortTable): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta): model = models.RearPort fields = ( - 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', + 'pk', 'name', 'label', 'type', 'positions', 'assignments', 'description', 'cable', 'link_peer', ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 979689b75..0872832fa 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -249,12 +249,13 @@ class InterfaceTemplateTable(ComponentTemplateTable): class FrontPortTemplateTable(ComponentTemplateTable): - rear_port_position = tables.Column( - verbose_name=_('Position') - ) color = columns.ColorColumn( verbose_name=_('Color'), ) + assignments = columns.ManyToManyColumn( + verbose_name=_('Assignments'), + transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}' + ) actions = columns.ActionsColumn( actions=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -262,7 +263,7 @@ class FrontPortTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = models.FrontPortTemplate - fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions') + fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'assignments', 'description', 'actions') empty_text = "None" @@ -270,6 +271,10 @@ class RearPortTemplateTable(ComponentTemplateTable): color = columns.ColorColumn( verbose_name=_('Color'), ) + assignments = columns.ManyToManyColumn( + verbose_name=_('Assignments'), + transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}' + ) actions = columns.ActionsColumn( actions=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS @@ -277,7 +282,7 @@ class RearPortTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = models.RearPortTemplate - fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions') + fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'assignments', 'description', 'actions') empty_text = "None" From b538ff80d5431ed321a4323c7d70c46e63c292be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 15:02:16 -0500 Subject: [PATCH 32/51] Clean up tests --- netbox/dcim/forms/bulk_import.py | 28 -------- netbox/dcim/tests/test_forms.py | 6 +- netbox/dcim/tests/test_models.py | 28 +++++--- netbox/dcim/tests/test_views.py | 116 +++++++++++++++---------------- 4 files changed, 81 insertions(+), 97 deletions(-) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index eb41af0eb..68258aa8b 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1075,12 +1075,6 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm): queryset=Device.objects.all(), to_field_name='name' ) - rear_port = CSVModelChoiceField( - label=_('Rear port'), - queryset=RearPort.objects.all(), - to_field_name='name', - help_text=_('Corresponding rear port') - ) type = CSVChoiceField( label=_('Type'), choices=PortTypeChoices, @@ -1093,28 +1087,6 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm): 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags' ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit RearPort choices to those belonging to this device (or VC master) - if self.is_bound and 'device' in self.data: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - device = None - else: - try: - device = self.instance.device - except Device.DoesNotExist: - device = None - - if device: - self.fields['rear_port'].queryset = RearPort.objects.filter( - device__in=[device, device.get_vc_master()] - ) - else: - self.fields['rear_port'].queryset = RearPort.objects.none() - class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm): device = CSVModelChoiceField( diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index fa654f789..a911cbf25 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -193,7 +193,8 @@ class FrontPortTestCase(TestCase): 'name': 'FrontPort[1-4]', 'label': 'Port[1-4]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports], + 'positions': 1, + 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports], } form = FrontPortCreateForm(front_port_data) @@ -208,7 +209,8 @@ class FrontPortTestCase(TestCase): 'name': 'FrontPort[1-4]', 'label': 'Port[1-2]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports], + 'positions': 1, + 'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports], } form = FrontPortCreateForm(bad_front_port_data) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 877af600b..dabd076d9 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -444,13 +444,18 @@ class DeviceTestCase(TestCase): ) rearport.save() - FrontPortTemplate( + frontport = FrontPortTemplate( device_type=device_type, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, + ) + frontport.save() + + PortAssignmentTemplate.objects.create( + front_port=frontport, rear_port=rearport, - rear_port_position=2 - ).save() + rear_port_position=2, + ) ModuleBayTemplate( device_type=device_type, @@ -528,8 +533,7 @@ class DeviceTestCase(TestCase): device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, - rear_port=rearport, - rear_port_position=2 + positions=1 ) self.assertEqual(frontport.cf['cf1'], 'foo') @@ -835,12 +839,18 @@ class CableTestCase(TestCase): ) RearPort.objects.bulk_create(rear_ports) front_ports = ( - FrontPort(device=patch_panel, name='FP1', type='8p8c', rear_port=rear_ports[0], rear_port_position=1), - FrontPort(device=patch_panel, name='FP2', type='8p8c', rear_port=rear_ports[1], rear_port_position=1), - FrontPort(device=patch_panel, name='FP3', type='8p8c', rear_port=rear_ports[2], rear_port_position=1), - FrontPort(device=patch_panel, name='FP4', type='8p8c', rear_port=rear_ports[3], rear_port_position=1), + FrontPort(device=patch_panel, name='FP1', type='8p8c'), + FrontPort(device=patch_panel, name='FP2', type='8p8c'), + FrontPort(device=patch_panel, name='FP3', type='8p8c'), + FrontPort(device=patch_panel, name='FP4', type='8p8c'), ) FrontPort.objects.bulk_create(front_ports) + PortAssignment.objects.bulk_create([ + PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), + PortAssignment(front_port=front_ports[3], rear_port=rear_ports[3]), + ]) provider = Provider.objects.create(name='Provider 1', slug='provider-1') provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6a34df652..3a0dc3ca2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -741,17 +741,16 @@ class DeviceTypeTestCase( ) RearPortTemplate.objects.bulk_create(rear_ports) front_ports = ( - FrontPortTemplate( - device_type=devicetype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1 - ), - FrontPortTemplate( - device_type=devicetype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1 - ), - FrontPortTemplate( - device_type=devicetype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1 - ), + FrontPortTemplate(device_type=devicetype, name='Front Port 1'), + FrontPortTemplate(device_type=devicetype, name='Front Port 2'), + FrontPortTemplate(device_type=devicetype, name='Front Port 3'), ) FrontPortTemplate.objects.bulk_create(front_ports) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + ]) url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk}) self.assertHttpStatus(self.client.get(url), 200) @@ -971,8 +970,6 @@ inventory-items: self.assertEqual(device_type.frontporttemplates.count(), 3) fp1 = FrontPortTemplate.objects.first() self.assertEqual(fp1.name, 'Front Port 1') - self.assertEqual(fp1.rear_port, rp1) - self.assertEqual(fp1.rear_port_position, 1) self.assertEqual(device_type.modulebaytemplates.count(), 3) mb1 = ModuleBayTemplate.objects.first() @@ -1316,17 +1313,16 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) RearPortTemplate.objects.bulk_create(rear_ports) front_ports = ( - FrontPortTemplate( - module_type=moduletype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1 - ), - FrontPortTemplate( - module_type=moduletype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1 - ), - FrontPortTemplate( - module_type=moduletype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1 - ), + FrontPortTemplate(module_type=moduletype, name='Front Port 1'), + FrontPortTemplate(module_type=moduletype, name='Front Port 2'), + FrontPortTemplate(module_type=moduletype, name='Front Port 3'), ) FrontPortTemplate.objects.bulk_create(front_ports) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + ]) url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk}) self.assertHttpStatus(self.client.get(url), 200) @@ -1394,13 +1390,10 @@ rear-ports: front-ports: - name: Front Port 1 type: 8p8c - rear_port: Rear Port 1 - name: Front Port 2 type: 8p8c - rear_port: Rear Port 2 - name: Front Port 3 type: 8p8c - rear_port: Rear Port 3 module-bays: - name: Module Bay 1 position: 1 @@ -1477,8 +1470,6 @@ module-bays: self.assertEqual(module_type.frontporttemplates.count(), 3) fp1 = FrontPortTemplate.objects.first() self.assertEqual(fp1.name, 'Front Port 1') - self.assertEqual(fp1.rear_port, rp1) - self.assertEqual(fp1.rear_port_position, 1) self.assertEqual(module_type.modulebaytemplates.count(), 3) mb1 = ModuleBayTemplate.objects.first() @@ -1770,7 +1761,7 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') - rearports = ( + rear_ports = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'), RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'), RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'), @@ -1778,35 +1769,33 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'), RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'), ) - RearPortTemplate.objects.bulk_create(rearports) - - FrontPortTemplate.objects.bulk_create( - ( - FrontPortTemplate( - device_type=devicetype, name='Front Port Template 1', rear_port=rearports[0], rear_port_position=1 - ), - FrontPortTemplate( - device_type=devicetype, name='Front Port Template 2', rear_port=rearports[1], rear_port_position=1 - ), - FrontPortTemplate( - device_type=devicetype, name='Front Port Template 3', rear_port=rearports[2], rear_port_position=1 - ), - ) + RearPortTemplate.objects.bulk_create(rear_ports) + front_ports = ( + FrontPortTemplate(device_type=devicetype, name='Front Port Template 1'), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 2'), + FrontPortTemplate(device_type=devicetype, name='Front Port Template 3'), ) + FrontPortTemplate.objects.bulk_create(front_ports) + PortAssignmentTemplate.objects.bulk_create([ + PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + ]) cls.form_data = { 'device_type': devicetype.pk, 'name': 'Front Port X', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rearports[3].pk, - 'rear_port_position': 1, + 'positions': 1, + 'rear_ports': [f'{rear_ports[3].pk}:1'], } cls.bulk_create_data = { 'device_type': devicetype.pk, 'name': 'Front Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]], + 'positions': 1, + 'rear_ports': [f'{rp.pk}:1' for rp in rear_ports[3:6]], } cls.bulk_edit_data = { @@ -2276,11 +2265,16 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) RearPort.objects.bulk_create(rear_ports) front_ports = ( - FrontPort(device=device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1), - FrontPort(device=device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1), - FrontPort(device=device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1), + FrontPort(device=device, name='Front Port Template 1'), + FrontPort(device=device, name='Front Port Template 2'), + FrontPort(device=device, name='Front Port Template 3'), ) FrontPort.objects.bulk_create(front_ports) + PortAssignment.objects.bulk_create([ + PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), + ]) url = reverse('dcim:device_frontports', kwargs={'pk': device.pk}) self.assertHttpStatus(self.client.get(url), 200) @@ -3065,7 +3059,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): def setUpTestData(cls): device = create_test_device('Device 1') - rearports = ( + rear_ports = ( RearPort(device=device, name='Rear Port 1'), RearPort(device=device, name='Rear Port 2'), RearPort(device=device, name='Rear Port 3'), @@ -3073,14 +3067,19 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): RearPort(device=device, name='Rear Port 5'), RearPort(device=device, name='Rear Port 6'), ) - RearPort.objects.bulk_create(rearports) + RearPort.objects.bulk_create(rear_ports) front_ports = ( - FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]), - FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]), - FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), + FrontPort(device=device, name='Front Port 1'), + FrontPort(device=device, name='Front Port 2'), + FrontPort(device=device, name='Front Port 3'), ) FrontPort.objects.bulk_create(front_ports) + PortAssignment.objects.bulk_create([ + PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), + PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), + PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), + ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -3088,8 +3087,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'device': device.pk, 'name': 'Front Port X', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rearports[3].pk, - 'rear_port_position': 1, + 'positions': 1, + 'rear_ports': [f'{rear_ports[3].pk}:1'], 'description': 'New description', 'tags': [t.pk for t in tags], } @@ -3098,7 +3097,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): 'device': device.pk, 'name': 'Front Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]], + 'positions': 1, + 'rear_ports': [f'{rp.pk}:1' for rp in rear_ports[3:6]], 'description': 'New description', 'tags': [t.pk for t in tags], } @@ -3109,10 +3109,10 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - "device,name,type,rear_port,rear_port_position", - "Device 1,Front Port 4,8p8c,Rear Port 4,1", - "Device 1,Front Port 5,8p8c,Rear Port 5,1", - "Device 1,Front Port 6,8p8c,Rear Port 6,1", + "device,name,type,positions", + "Device 1,Front Port 4,8p8c,1", + "Device 1,Front Port 5,8p8c,1", + "Device 1,Front Port 6,8p8c,1", ) cls.csv_update_data = ( From d2afab96623de91627bfd4877b039d3ab8498916 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 16:13:22 -0500 Subject: [PATCH 33/51] Remove obsolete GraphQL filters --- netbox/dcim/graphql/filters.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 398c065a6..263025c1b 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -404,11 +404,7 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin): type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() - rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( - strawberry_django.filter_field() - ) - rear_port_id: ID | None = strawberry_django.filter_field() - rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + rear_ports: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) From b993ec978aef651688e64427361b5ad243641b69 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 09:56:42 -0500 Subject: [PATCH 34/51] Rename port assignments to port mappings --- .../api/serializers_/device_components.py | 58 +++--- .../api/serializers_/devicetype_components.py | 56 +++--- netbox/dcim/filtersets.py | 8 +- netbox/dcim/forms/mixins.py | 18 +- netbox/dcim/forms/model_forms.py | 20 +- netbox/dcim/graphql/types.py | 20 +- ...t_assignments.py => 0222_port_mappings.py} | 52 ++--- .../migrations/0223_frontport_positions.py | 2 +- netbox/dcim/models/base.py | 8 +- netbox/dcim/models/cables.py | 22 +-- .../dcim/models/device_component_templates.py | 16 +- netbox/dcim/models/device_components.py | 18 +- netbox/dcim/models/devices.py | 8 +- netbox/dcim/signals.py | 4 +- netbox/dcim/tables/devices.py | 24 +-- netbox/dcim/tables/devicetypes.py | 12 +- netbox/dcim/tests/test_api.py | 26 +-- netbox/dcim/tests/test_cablepaths.py | 180 +++++++++--------- netbox/dcim/tests/test_cablepaths2.py | 30 +-- netbox/dcim/tests/test_filtersets.py | 42 ++-- netbox/dcim/tests/test_models.py | 12 +- netbox/dcim/tests/test_views.py | 40 ++-- netbox/dcim/utils.py | 9 +- netbox/dcim/views.py | 6 +- netbox/templates/dcim/frontport.html | 8 +- netbox/templates/dcim/rearport.html | 8 +- 26 files changed, 353 insertions(+), 354 deletions(-) rename netbox/dcim/migrations/{0222_m2m_port_assignments.py => 0222_port_mappings.py} (78%) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 454977839..8d7f3be6f 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -5,7 +5,7 @@ from rest_framework import serializers from dcim.choices import * from dcim.constants import * from dcim.models import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortAssignment, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping, PowerOutlet, PowerPort, RearPort, VirtualDeviceContext, ) from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer @@ -294,7 +294,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect return super().validate(data) -class RearPortAssignmentSerializer(serializers.ModelSerializer): +class RearPortMappingSerializer(serializers.ModelSerializer): position = serializers.IntegerField( source='rear_port_position' ) @@ -303,7 +303,7 @@ class RearPortAssignmentSerializer(serializers.ModelSerializer): ) class Meta: - model = PortAssignment + model = PortMapping fields = ('position', 'front_port', 'front_port_position') @@ -316,8 +316,8 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): allow_null=True ) type = ChoiceField(choices=PortTypeChoices) - front_ports = RearPortAssignmentSerializer( - source='assignments', + front_ports = RearPortMappingSerializer( + source='mappings', many=True, required=False, ) @@ -332,29 +332,29 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') def create(self, validated_data): - assignments = validated_data.pop('assignments', []) + mappings = validated_data.pop('mappings', []) instance = super().create(validated_data) - # Create FrontPort assignments - for assignment_data in assignments: - PortAssignment.objects.create(rear_port=instance, **assignment_data) + # Create FrontPort mappings + for attrs in mappings: + PortMapping.objects.create(rear_port=instance, **attrs) return instance def update(self, instance, validated_data): - assignments = validated_data.pop('assignments', None) + mappings = validated_data.pop('mappings', None) instance = super().update(instance, validated_data) - if assignments is not None: - # Update FrontPort assignments - PortAssignment.objects.filter(rear_port=instance).delete() - for assignment_data in assignments: - PortAssignment.objects.create(rear_port=instance, **assignment_data) + if mappings is not None: + # Update FrontPort mappings + PortMapping.objects.filter(rear_port=instance).delete() + for attrs in mappings: + PortMapping.objects.create(rear_port=instance, **attrs) return instance -class FrontPortAssignmentSerializer(serializers.ModelSerializer): +class FrontPortMappingSerializer(serializers.ModelSerializer): position = serializers.IntegerField( source='front_port_position' ) @@ -363,7 +363,7 @@ class FrontPortAssignmentSerializer(serializers.ModelSerializer): ) class Meta: - model = PortAssignment + model = PortMapping fields = ('position', 'rear_port', 'rear_port_position') @@ -376,8 +376,8 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): allow_null=True ) type = ChoiceField(choices=PortTypeChoices) - rear_ports = FrontPortAssignmentSerializer( - source='assignments', + rear_ports = FrontPortMappingSerializer( + source='mappings', many=True, required=False, ) @@ -392,24 +392,24 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') def create(self, validated_data): - assignments = validated_data.pop('assignments', []) + mappings = validated_data.pop('mappings', []) instance = super().create(validated_data) - # Create RearPort assignments - for assignment_data in assignments: - PortAssignment.objects.create(front_port=instance, **assignment_data) + # Create RearPort mappings + for attrs in mappings: + PortMapping.objects.create(front_port=instance, **attrs) return instance def update(self, instance, validated_data): - assignments = validated_data.pop('assignments', None) + mappings = validated_data.pop('mappings', None) instance = super().update(instance, validated_data) - if assignments is not None: - # Update RearPort assignments - PortAssignment.objects.filter(front_port=instance).delete() - for assignment_data in assignments: - PortAssignment.objects.create(front_port=instance, **assignment_data) + if mappings is not None: + # Update RearPort mappings + PortMapping.objects.filter(front_port=instance).delete() + for attrs in mappings: + PortMapping.objects.create(front_port=instance, **attrs) return instance diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 7f818fcd4..05bcff776 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -5,7 +5,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, - InventoryItemTemplate, ModuleBayTemplate, PortAssignmentTemplate, PowerOutletTemplate, PowerPortTemplate, + InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from netbox.api.fields import ChoiceField, ContentTypeField @@ -206,7 +206,7 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class RearPortTemplateAssignmentSerializer(serializers.ModelSerializer): +class RearPortTemplateMappingSerializer(serializers.ModelSerializer): position = serializers.IntegerField( source='rear_port_position' ) @@ -215,7 +215,7 @@ class RearPortTemplateAssignmentSerializer(serializers.ModelSerializer): ) class Meta: - model = PortAssignmentTemplate + model = PortTemplateMapping fields = ('position', 'front_port', 'front_port_position') @@ -233,8 +233,8 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer): default=None ) type = ChoiceField(choices=PortTypeChoices) - front_ports = RearPortTemplateAssignmentSerializer( - source='assignments', + front_ports = RearPortTemplateMappingSerializer( + source='mappings', many=True, required=False, ) @@ -248,29 +248,29 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') def create(self, validated_data): - assignments = validated_data.pop('assignments', []) + mappings = validated_data.pop('mappings', []) instance = super().create(validated_data) - # Create FrontPort assignments - for assignment_data in assignments: - PortAssignmentTemplate.objects.create(rear_port=instance, **assignment_data) + # Create FrontPort mappings + for attrs in mappings: + PortTemplateMapping.objects.create(rear_port=instance, **attrs) return instance def update(self, instance, validated_data): - assignments = validated_data.pop('assignments', None) + mappings = validated_data.pop('mappings', None) instance = super().update(instance, validated_data) - if assignments is not None: - # Update FrontPort assignments - PortAssignmentTemplate.objects.filter(rear_port=instance).delete() - for assignment_data in assignments: - PortAssignmentTemplate.objects.create(rear_port=instance, **assignment_data) + if mappings is not None: + # Update FrontPort mappings + PortTemplateMapping.objects.filter(rear_port=instance).delete() + for attrs in mappings: + PortTemplateMapping.objects.create(rear_port=instance, **attrs) return instance -class FrontPortTemplateAssignmentSerializer(serializers.ModelSerializer): +class FrontPortTemplateMappingSerializer(serializers.ModelSerializer): position = serializers.IntegerField( source='front_port_position' ) @@ -279,7 +279,7 @@ class FrontPortTemplateAssignmentSerializer(serializers.ModelSerializer): ) class Meta: - model = PortAssignmentTemplate + model = PortTemplateMapping fields = ('position', 'rear_port', 'rear_port_position') @@ -297,8 +297,8 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer): default=None ) type = ChoiceField(choices=PortTypeChoices) - rear_ports = FrontPortTemplateAssignmentSerializer( - source='assignments', + rear_ports = FrontPortTemplateMappingSerializer( + source='mappings', many=True, required=False, ) @@ -312,24 +312,24 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') def create(self, validated_data): - assignments = validated_data.pop('assignments', []) + mappings = validated_data.pop('mappings', []) instance = super().create(validated_data) - # Create RearPort assignments - for assignment_data in assignments: - PortAssignmentTemplate.objects.create(front_port=instance, **assignment_data) + # Create RearPort mappings + for attrs in mappings: + PortTemplateMapping.objects.create(front_port=instance, **attrs) return instance def update(self, instance, validated_data): - assignments = validated_data.pop('assignments', None) + mappings = validated_data.pop('mappings', None) instance = super().update(instance, validated_data) - if assignments is not None: + if mappings is not None: # Update RearPort assignments - PortAssignmentTemplate.objects.filter(front_port=instance).delete() - for assignment_data in assignments: - PortAssignmentTemplate.objects.create(front_port=instance, **assignment_data) + PortTemplateMapping.objects.filter(front_port=instance).delete() + for attrs in mappings: + PortTemplateMapping.objects.create(front_port=instance, **attrs) return instance diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 47ec1a0c6..7f1f1a668 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -885,7 +885,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo null_value=None ) rear_port_id = django_filters.ModelMultipleChoiceFilter( - field_name='assignments__rear_port', + field_name='mappings__rear_port', queryset=RearPort.objects.all(), to_field_name='rear_port', label=_('Rear port (ID)'), @@ -902,7 +902,7 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom null_value=None ) front_port_id = django_filters.ModelMultipleChoiceFilter( - field_name='assignments__front_port', + field_name='mappings__front_port', queryset=FrontPort.objects.all(), to_field_name='front_port', label=_('Front port (ID)'), @@ -2111,7 +2111,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) null_value=None ) rear_port_id = django_filters.ModelMultipleChoiceFilter( - field_name='assignments__rear_port', + field_name='mappings__rear_port', queryset=RearPort.objects.all(), to_field_name='rear_port', label=_('Rear port (ID)'), @@ -2131,7 +2131,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): null_value=None ) front_port_id = django_filters.ModelMultipleChoiceFilter( - field_name='assignments__front_port', + field_name='mappings__front_port', queryset=FrontPort.objects.all(), to_field_name='front_port', label=_('Front port (ID)'), diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 075742642..db9252b07 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -4,7 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.constants import LOCATION_SCOPE_TYPES -from dcim.models import PortAssignmentTemplate, Site +from dcim.models import PortTemplateMapping, Site from utilities.forms import get_field_value from utilities.forms.fields import ( ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField, @@ -138,7 +138,7 @@ class FrontPortFormMixin(forms.Form): widget=forms.SelectMultiple(attrs={'size': 8}) ) - port_assignment_model = PortAssignmentTemplate + port_mapping_model = PortTemplateMapping def clean(self): super().clean() @@ -161,19 +161,19 @@ class FrontPortFormMixin(forms.Form): super()._save_m2m() # TODO: Can this be made more efficient? - # Delete existing rear port assignments - self.port_assignment_model.objects.filter(front_port_id=self.instance.pk).delete() + # Delete existing rear port mappings + self.port_mapping_model.objects.filter(front_port_id=self.instance.pk).delete() - # Create new rear port assignments - assignments = [] + # Create new rear port mappings + mappings = [] for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1): rear_port_id, rear_port_position = rp_position.split(':') - assignments.append( - self.port_assignment_model( + mappings.append( + self.port_mapping_model( front_port_id=self.instance.pk, front_port_position=i, rear_port_id=rear_port_id, rear_port_position=rear_port_position, ) ) - self.port_assignment_model.objects.bulk_create(assignments) + self.port_mapping_model.objects.bulk_create(mappings) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 09755ce33..8471a7163 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1124,7 +1124,7 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): ), ) - port_assignment_model = PortAssignmentTemplate + port_mapping_model = PortTemplateMapping parent_field = 'device_type' class Meta: @@ -1144,11 +1144,11 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): # Populate rear port choices self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance) - # Set initial rear port assignments + # Set initial rear port mappings if self.instance.pk: self.initial['rear_ports'] = [ - f'{assignment.rear_port_id}:{assignment.rear_port_position}' - for assignment in PortAssignmentTemplate.objects.filter(front_port_id=self.instance.pk) + f'{mapping.rear_port_id}:{mapping.rear_port_position}' + for mapping in PortTemplateMapping.objects.filter(front_port_id=self.instance.pk) ] def _get_rear_port_choices(self, device_type, front_port): @@ -1158,7 +1158,7 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): """ occupied_rear_port_positions = [ f'{assignment.rear_port_id}:{assignment.rear_port_position}' - for assignment in PortAssignmentTemplate.objects.filter( + for assignment in PortTemplateMapping.objects.filter( front_port__device_type=device_type ).exclude(front_port=front_port.pk) ] @@ -1620,7 +1620,7 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): ), ) - port_assignment_model = PortAssignment + port_mapping_model = PortMapping class Meta: model = FrontPort @@ -1640,11 +1640,11 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): # Populate rear port choices self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance) - # Set initial rear port assignments + # Set initial rear port mappings if self.instance.pk: self.initial['rear_ports'] = [ - f'{assignment.rear_port_id}:{assignment.rear_port_position}' - for assignment in PortAssignment.objects.filter(front_port_id=self.instance.pk) + f'{mapping.rear_port_id}:{mapping.rear_port_position}' + for mapping in PortMapping.objects.filter(front_port_id=self.instance.pk) ] def _get_rear_port_choices(self, device, front_port): @@ -1654,7 +1654,7 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): """ occupied_rear_port_positions = [ f'{assignment.rear_port_id}:{assignment.rear_port_position}' - for assignment in PortAssignment.objects.filter(front_port__device=device).exclude(front_port=front_port.pk) + for assignment in PortMapping.objects.filter(front_port__device=device).exclude(front_port=front_port.pk) ] choices = [] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index d9e5cd4d3..89f8d2f2b 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -386,7 +386,7 @@ class DeviceTypeType(PrimaryObjectType): class FrontPortType(ModularComponentType, CabledObjectMixin): color: str - assignments: List[Annotated["PortAssignmentType", strawberry.lazy('dcim.graphql.types')]] + mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -398,7 +398,7 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): class FrontPortTemplateType(ModularComponentTemplateType): color: str - assignments: List[Annotated["PortAssignmentTemplateType", strawberry.lazy('dcim.graphql.types')]] + mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -639,23 +639,23 @@ class PlatformType(NestedGroupObjectType): @strawberry_django.type( - models.PortAssignment, + models.PortMapping, fields='__all__', - # filters=PortAssignmentFilter, + # filters=PortMappingFilter, pagination=True ) -class PortAssignmentType(ModularComponentTemplateType): +class PortMappingType(ModularComponentTemplateType): front_port: Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')] rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] @strawberry_django.type( - models.PortAssignmentTemplate, + models.PortTemplateMapping, fields='__all__', - # filters=PortAssignmentTemplateFilter, + # filters=PortMappingTemplateFilter, pagination=True ) -class PortAssignmentTemplateType(ModularComponentTemplateType): +class PortMappingTemplateType(ModularComponentTemplateType): front_port_template: Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')] rear_port_template: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] @@ -792,7 +792,7 @@ class RackRoleType(OrganizationalObjectType): class RearPortType(ModularComponentType, CabledObjectMixin): color: str - assignments: List[Annotated["PortAssignmentType", strawberry.lazy('dcim.graphql.types')]] + mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( @@ -804,7 +804,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin): class RearPortTemplateType(ModularComponentTemplateType): color: str - assignments: List[Annotated["PortAssignmentTemplateType", strawberry.lazy('dcim.graphql.types')]] + mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( diff --git a/netbox/dcim/migrations/0222_m2m_port_assignments.py b/netbox/dcim/migrations/0222_port_mappings.py similarity index 78% rename from netbox/dcim/migrations/0222_m2m_port_assignments.py rename to netbox/dcim/migrations/0222_port_mappings.py index 714eced01..7978ca288 100644 --- a/netbox/dcim/migrations/0222_m2m_port_assignments.py +++ b/netbox/dcim/migrations/0222_port_mappings.py @@ -14,15 +14,15 @@ def chunked(iterable, size): yield chunk -def populate_port_template_assignments(apps, schema_editor): +def populate_port_template_mappings(apps, schema_editor): FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate') - PortAssignmentTemplate = apps.get_model('dcim', 'PortAssignmentTemplate') + PortTemplateMapping = apps.get_model('dcim', 'PortTemplateMapping') front_ports = FrontPortTemplate.objects.iterator(chunk_size=1000) def generate_copies(): for front_port in front_ports: - yield PortAssignmentTemplate( + yield PortTemplateMapping( front_port_id=front_port.pk, front_port_position=1, rear_port_id=front_port.rear_port_id, @@ -31,18 +31,18 @@ def populate_port_template_assignments(apps, schema_editor): # Bulk insert in streaming batches for chunk in chunked(generate_copies(), 1000): - PortAssignmentTemplate.objects.bulk_create(chunk, batch_size=1000) + PortTemplateMapping.objects.bulk_create(chunk, batch_size=1000) -def populate_port_assignments(apps, schema_editor): +def populate_port_mappings(apps, schema_editor): FrontPort = apps.get_model('dcim', 'FrontPort') - PortAssignment = apps.get_model('dcim', 'PortAssignment') + PortMapping = apps.get_model('dcim', 'PortMapping') front_ports = FrontPort.objects.iterator(chunk_size=1000) def generate_copies(): for front_port in front_ports: - yield PortAssignment( + yield PortMapping( front_port_id=front_port.pk, front_port_position=1, rear_port_id=front_port.rear_port_id, @@ -51,7 +51,7 @@ def populate_port_assignments(apps, schema_editor): # Bulk insert in streaming batches for chunk in chunked(generate_copies(), 1000): - PortAssignment.objects.bulk_create(chunk, batch_size=1000) + PortMapping.objects.bulk_create(chunk, batch_size=1000) class Migration(migrations.Migration): @@ -60,9 +60,9 @@ class Migration(migrations.Migration): ] operations = [ - # Create PortAssignmentTemplate model (for DeviceTypes) + # Create PortTemplateMapping model (for DeviceTypes) migrations.CreateModel( - name='PortAssignmentTemplate', + name='PortTemplateMapping', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ( @@ -90,7 +90,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, to='dcim.frontporttemplate', - related_name='assignments' + related_name='mappings' ) ), ( @@ -98,29 +98,29 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, to='dcim.rearporttemplate', - related_name='assignments' + related_name='mappings' ) ), ], ), migrations.AddConstraint( - model_name='portassignmenttemplate', + model_name='porttemplatemapping', constraint=models.UniqueConstraint( fields=('front_port', 'front_port_position'), - name='dcim_portassignmenttemplate_unique_front_port_position' + name='dcim_porttemplatemapping_unique_front_port_position' ), ), migrations.AddConstraint( - model_name='portassignmenttemplate', + model_name='porttemplatemapping', constraint=models.UniqueConstraint( fields=('rear_port', 'rear_port_position'), - name='dcim_portassignmenttemplate_unique_rear_port_position' + name='dcim_porttemplatemapping_unique_rear_port_position' ), ), - # Create PortAssignment model (for Devices) + # Create PortMapping model (for Devices) migrations.CreateModel( - name='PortAssignment', + name='PortMapping', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), ( @@ -148,7 +148,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, to='dcim.frontport', - related_name='assignments' + related_name='mappings' ) ), ( @@ -156,33 +156,33 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, to='dcim.rearport', - related_name='assignments' + related_name='mappings' ) ), ], ), migrations.AddConstraint( - model_name='portassignment', + model_name='portmapping', constraint=models.UniqueConstraint( fields=('front_port', 'front_port_position'), - name='dcim_portassignment_unique_front_port_position' + name='dcim_portmapping_unique_front_port_position' ), ), migrations.AddConstraint( - model_name='portassignment', + model_name='portmapping', constraint=models.UniqueConstraint( fields=('rear_port', 'rear_port_position'), - name='dcim_portassignment_unique_rear_port_position' + name='dcim_portmapping_unique_rear_port_position' ), ), # Data migration migrations.RunPython( - code=populate_port_template_assignments, + code=populate_port_template_mappings, reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=populate_port_assignments, + code=populate_port_mappings, reverse_code=migrations.RunPython.noop ), ] diff --git a/netbox/dcim/migrations/0223_frontport_positions.py b/netbox/dcim/migrations/0223_frontport_positions.py index dd0076b5a..fc3394738 100644 --- a/netbox/dcim/migrations/0223_frontport_positions.py +++ b/netbox/dcim/migrations/0223_frontport_positions.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0222_m2m_port_assignments'), + ('dcim', '0222_port_mappings'), ] operations = [ diff --git a/netbox/dcim/models/base.py b/netbox/dcim/models/base.py index 0689f1ff0..f75ddfefa 100644 --- a/netbox/dcim/models/base.py +++ b/netbox/dcim/models/base.py @@ -6,13 +6,13 @@ from django.utils.translation import gettext_lazy as _ from dcim.constants import PORT_POSITION_MAX, PORT_POSITION_MIN __all__ = ( - 'PortAssignmentBase', + 'PortMappingBase', ) -class PortAssignmentBase(models.Model): +class PortMappingBase(models.Model): """ - Base class for PortAssignment and PortAssignment Template + Base class for PortMapping and PortTemplateMapping """ front_port_position = models.PositiveSmallIntegerField( default=1, @@ -45,7 +45,7 @@ class PortAssignmentBase(models.Model): def clean(self): super().clean() - # Validate rear port position assignment + # Validate rear port position if self.rear_port_position > self.rear_port.positions: raise ValidationError({ "rear_port_position": _( diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 0a654bff1..1c1d67538 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -22,7 +22,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, PathEndpoint, PortAssignment, RearPort +from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort __all__ = ( 'Cable', @@ -795,19 +795,19 @@ class CablePath(models.Model): q_filter = Q() for rt in remote_terminations: q_filter |= Q(front_port=rt, front_port_position__in=positions) - port_assignments = PortAssignment.objects.filter(q_filter) + port_mappings = PortMapping.objects.filter(q_filter) elif remote_terminations[0].positions > 1: is_split = True break else: - port_assignments = PortAssignment.objects.filter(front_port__in=remote_terminations) - if not port_assignments: + port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations) + if not port_mappings: break # Compile the list of RearPorts without duplication or altering their ordering - terminations = list(dict.fromkeys(assignment.rear_port for assignment in port_assignments)) + terminations = list(dict.fromkeys(mapping.rear_port for mapping in port_mappings)) if any(t.positions > 1 for t in terminations): - position_stack.append([assignment.rear_port_position for assignment in port_assignments]) + position_stack.append([mapping.rear_port_position for mapping in port_mappings]) elif isinstance(remote_terminations[0], RearPort): # Follow RearPorts to their corresponding FrontPorts @@ -816,19 +816,19 @@ class CablePath(models.Model): q_filter = Q() for rt in remote_terminations: q_filter |= Q(rear_port=rt, rear_port_position__in=positions) - port_assignments = PortAssignment.objects.filter(q_filter) + port_mappings = PortMapping.objects.filter(q_filter) elif remote_terminations[0].positions > 1: is_split = True break else: - port_assignments = PortAssignment.objects.filter(rear_port__in=remote_terminations) - if not port_assignments: + port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations) + if not port_mappings: break # Compile the list of FrontPorts without duplication or altering their ordering - terminations = list(dict.fromkeys(assignment.front_port for assignment in port_assignments)) + terminations = list(dict.fromkeys(mapping.front_port for mapping in port_mappings)) if any(t.positions > 1 for t in terminations): - position_stack.append([assignment.front_port_position for assignment in port_assignments]) + position_stack.append([mapping.front_port_position for mapping in port_mappings]) elif isinstance(remote_terminations[0], CircuitTermination): # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index d58e4ee44..090ae2c08 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -7,7 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * -from dcim.models.base import PortAssignmentBase +from dcim.models.base import PortMappingBase from dcim.models.mixins import InterfaceValidationMixin from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField @@ -29,7 +29,7 @@ __all__ = ( 'InterfaceTemplate', 'InventoryItemTemplate', 'ModuleBayTemplate', - 'PortAssignmentTemplate', + 'PortTemplateMapping', 'PowerOutletTemplate', 'PowerPortTemplate', 'RearPortTemplate', @@ -520,19 +520,19 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) } -class PortAssignmentTemplate(PortAssignmentBase): +class PortTemplateMapping(PortMappingBase): """ Maps a FrontPortTemplate & position to a RearPortTemplate & position. """ front_port = models.ForeignKey( to='dcim.FrontPortTemplate', on_delete=models.CASCADE, - related_name='assignments', + related_name='mappings', ) rear_port = models.ForeignKey( to='dcim.RearPortTemplate', on_delete=models.CASCADE, - related_name='assignments', + related_name='mappings', ) def clean(self): @@ -640,13 +640,13 @@ class RearPortTemplate(ModularComponentTemplateModel): # Check that positions count is greater than or equal to the number of associated FrontPortTemplates if not self._state.adding: - assignment_count = self.assignments.count() - if self.positions < assignment_count: + mapping_count = self.mappings.count() + if self.positions < mapping_count: raise ValidationError({ "positions": _( "The number of positions cannot be less than the number of mapped front port templates " "({count})" - ).format(count=assignment_count) + ).format(count=mapping_count) }) def instantiate(self, **kwargs): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e77ba2db2..bc58c1ead 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,7 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.fields import WWNField -from dcim.models.base import PortAssignmentBase +from dcim.models.base import PortMappingBase from dcim.models.mixins import InterfaceValidationMixin from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, NetBoxModel @@ -36,7 +36,7 @@ __all__ = ( 'InventoryItemRole', 'ModuleBay', 'PathEndpoint', - 'PortAssignment', + 'PortMapping', 'PowerOutlet', 'PowerPort', 'RearPort', @@ -1071,25 +1071,25 @@ class Interface( # Pass-through ports # -class PortAssignment(PortAssignmentBase): +class PortMapping(PortMappingBase): """ Maps a FrontPort & position to a RearPort & position. """ front_port = models.ForeignKey( to='dcim.FrontPort', on_delete=models.CASCADE, - related_name='assignments', + related_name='mappings', ) rear_port = models.ForeignKey( to='dcim.RearPort', on_delete=models.CASCADE, - related_name='assignments', + related_name='mappings', ) def clean(self): super().clean() - # Validate rear port assignment + # Both ports must belong to the same device if self.front_port.device_id != self.rear_port.device_id: raise ValidationError({ "rear_port": _("Rear port ({rear_port}) must belong to the same device").format( @@ -1166,13 +1166,13 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): # Check that positions count is greater than or equal to the number of associated FrontPorts if not self._state.adding: - assignment_count = self.assignments.count() - if self.positions < assignment_count: + mapping_count = self.mappings.count() + if self.positions < mapping_count: raise ValidationError({ "positions": _( "The number of positions cannot be less than the number of mapped front ports " "({count})" - ).format(count=assignment_count) + ).format(count=mapping_count) }) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index a315a5643..2679b7296 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField -from dcim.utils import create_port_assignments, update_interface_bridges +from dcim.utils import create_port_mappings, update_interface_bridges from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices @@ -30,7 +30,7 @@ from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField from utilities.prefetch import get_prefetchable_fields from utilities.tracking import TrackingModelMixin -from . import PortAssignmentTemplate +from . import PortTemplateMapping from .device_components import * from .mixins import RenderConfigMixin from .modules import Module @@ -1011,8 +1011,8 @@ class Device( self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) # Interface bridges have to be set after interface instantiation update_interface_bridges(self, self.device_type.interfacetemplates.all()) - # Replicate any front/rear port assignments from the DeviceType - create_port_assignments(self, PortAssignmentTemplate.objects.filter( + # Replicate any front/rear port mappings from the DeviceType + create_port_mappings(self, PortTemplateMapping.objects.filter( front_port__device_type=self.device_type )) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 10daa2ee0..d9edaaa98 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -156,8 +156,8 @@ def extend_rearport_cable_paths(instance, created, raw, **kwargs): When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort. """ if created and not raw: - for assignment in instance.assignments.prefetch_related('rear_port'): - for cablepath in CablePath.objects.filter(_nodes__contains=assignment.rear_port): + for mapping in instance.mappings.prefetch_related('rear_port'): + for cablepath in CablePath.objects.filter(_nodes__contains=mapping.rear_port): cablepath.retrace() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d24987265..2b5a3eeff 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -749,8 +749,8 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): color = columns.ColorColumn( verbose_name=_('Color'), ) - assignments = columns.ManyToManyColumn( - verbose_name=_('Assignments'), + mappings = columns.ManyToManyColumn( + verbose_name=_('Mappings'), transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}' ) tags = columns.TagColumn( @@ -760,12 +760,12 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = models.FrontPort fields = ( - 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'assignments', 'description', + 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'mappings', 'description', ) @@ -783,11 +783,11 @@ class DeviceFrontPortTable(FrontPortTable): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta): model = models.FrontPort fields = ( - 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'color', 'positions', 'assignments', 'description', 'cable', 'link_peer', + 'pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'cable', 'link_peer', ) @@ -802,8 +802,8 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): color = columns.ColorColumn( verbose_name=_('Color'), ) - assignments = columns.ManyToManyColumn( - verbose_name=_('Assignments'), + mappings = columns.ManyToManyColumn( + verbose_name=_('Mappings'), transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}' ) tags = columns.TagColumn( @@ -813,12 +813,12 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = models.RearPort fields = ( - 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'assignments', 'description', + 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'mappings', 'description', ) @@ -836,11 +836,11 @@ class DeviceRearPortTable(RearPortTable): class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta): model = models.RearPort fields = ( - 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'assignments', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'assignments', 'description', 'cable', 'link_peer', + 'pk', 'name', 'label', 'type', 'positions', 'mappings', 'description', 'cable', 'link_peer', ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 0872832fa..b7ad758df 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -252,8 +252,8 @@ class FrontPortTemplateTable(ComponentTemplateTable): color = columns.ColorColumn( verbose_name=_('Color'), ) - assignments = columns.ManyToManyColumn( - verbose_name=_('Assignments'), + mappings = columns.ManyToManyColumn( + verbose_name=_('Mappings'), transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}' ) actions = columns.ActionsColumn( @@ -263,7 +263,7 @@ class FrontPortTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = models.FrontPortTemplate - fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'assignments', 'description', 'actions') + fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'actions') empty_text = "None" @@ -271,8 +271,8 @@ class RearPortTemplateTable(ComponentTemplateTable): color = columns.ColorColumn( verbose_name=_('Color'), ) - assignments = columns.ManyToManyColumn( - verbose_name=_('Assignments'), + mappings = columns.ManyToManyColumn( + verbose_name=_('Mappings'), transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}' ) actions = columns.ActionsColumn( @@ -282,7 +282,7 @@ class RearPortTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = models.RearPortTemplate - fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'assignments', 'description', 'actions') + fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'actions') empty_text = "None" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 69323180f..7f17b7683 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -983,10 +983,10 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): FrontPortTemplate(module_type=moduletype, name='Front Port Template 3', type=PortTypeChoices.TYPE_8P8C), ) FrontPortTemplate.objects.bulk_create(front_port_templates) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), - PortAssignmentTemplate(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), - PortAssignmentTemplate(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), + PortTemplateMapping(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), + PortTemplateMapping(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), ]) cls.create_data = [ @@ -1061,10 +1061,10 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_port_templates) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), - PortAssignmentTemplate(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), - PortAssignmentTemplate(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), + PortTemplateMapping(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), + PortTemplateMapping(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), ]) cls.create_data = [ @@ -2040,10 +2040,10 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C), ) FrontPort.objects.bulk_create(front_ports) - PortAssignment.objects.bulk_create([ - PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping.objects.bulk_create([ + PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), ]) cls.create_data = [ @@ -2091,7 +2091,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): interface1 = Interface.objects.create(device=device, name='Interface 1') rear_port = RearPort.objects.create(device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C) front_port = FrontPort.objects.create(device=device, name='Front Port 10', type=PortTypeChoices.TYPE_8P8C) - PortAssignment.objects.create(front_port=front_port, rear_port=rear_port) + PortMapping.objects.create(front_port=front_port, rear_port=rear_port) Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port]) self.add_permissions(f'dcim.view_{self.model._meta.model_name}') diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index cd9253080..bbedd856e 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -283,7 +283,7 @@ class LegacyCablePathTests(CablePathTestCase): interface2 = Interface.objects.create(device=self.device, name='Interface 2') rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') - PortAssignment.objects.create( + PortMapping.objects.create( front_port=frontport1, front_port_position=1, rear_port=rearport1, @@ -346,7 +346,7 @@ class LegacyCablePathTests(CablePathTestCase): interface4 = Interface.objects.create(device=self.device, name='Interface 4') rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') - PortAssignment.objects.create( + PortMapping.objects.create( front_port=frontport1, front_port_position=1, rear_port=rearport1, @@ -415,17 +415,17 @@ class LegacyCablePathTests(CablePathTestCase): frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) @@ -539,17 +539,17 @@ class LegacyCablePathTests(CablePathTestCase): frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) @@ -709,23 +709,23 @@ class LegacyCablePathTests(CablePathTestCase): frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') frontport4_1 = FrontPort.objects.create(device=self.device, name='Front Port 4:1') frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, ), ]) @@ -837,29 +837,29 @@ class LegacyCablePathTests(CablePathTestCase): frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2') frontport4_1 = FrontPort.objects.create(device=self.device, name='Front Port 4:1') frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, ), ]) @@ -973,20 +973,20 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') frontport3_1 = FrontPort.objects.create(device=self.device, name='Front Port 3:1') frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, ), ]) @@ -1080,11 +1080,11 @@ class LegacyCablePathTests(CablePathTestCase): rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2) frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1') frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), ]) @@ -1150,7 +1150,7 @@ class LegacyCablePathTests(CablePathTestCase): rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') - PortAssignment.objects.create( + PortMapping.objects.create( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ) @@ -1467,17 +1467,17 @@ class LegacyCablePathTests(CablePathTestCase): frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) @@ -1665,17 +1665,17 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) @@ -1758,29 +1758,29 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') frontport2_3 = FrontPort.objects.create(device=self.device, name='Front Port 2:3') frontport2_4 = FrontPort.objects.create(device=self.device, name='Front Port 2:4') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport1_3, front_port_position=1, rear_port=rearport1, rear_port_position=3, ), - PortAssignment( + PortMapping( front_port=frontport1_4, front_port_position=1, rear_port=rearport1, rear_port_position=4, ), - PortAssignment( + PortMapping( front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2_3, front_port_position=1, rear_port=rearport2, rear_port_position=3, ), - PortAssignment( + PortMapping( front_port=frontport2_4, front_port_position=1, rear_port=rearport2, rear_port_position=4, ), ]) @@ -1938,17 +1938,17 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) @@ -2023,17 +2023,17 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) @@ -2129,23 +2129,23 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') frontport5 = FrontPort.objects.create(device=self.device, name='Front Port 5') frontport6 = FrontPort.objects.create(device=self.device, name='Front Port 6') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport5, front_port_position=1, rear_port=rearport5, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport6, front_port_position=1, rear_port=rearport6, rear_port_position=1, ), ]) @@ -2251,11 +2251,11 @@ class LegacyCablePathTests(CablePathTestCase): rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2374,11 +2374,11 @@ class LegacyCablePathTests(CablePathTestCase): rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2424,11 +2424,11 @@ class LegacyCablePathTests(CablePathTestCase): rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) @@ -2479,8 +2479,8 @@ class LegacyCablePathTests(CablePathTestCase): interface2 = Interface.objects.create(device=self.device, name='Interface 2') rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), ]) @@ -2593,17 +2593,17 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3') frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, ), ]) diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 8a75fdcb5..3a12d755e 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -363,8 +363,8 @@ class CablePathTests(CablePathTestCase): ] rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), ]) @@ -441,17 +441,17 @@ class CablePathTests(CablePathTestCase): frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1') frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, ), - PortAssignment( + PortMapping( front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, ), ]) @@ -669,17 +669,17 @@ class CablePathTests(CablePathTestCase): FrontPort.objects.create(device=self.device, name='Front Port 3'), FrontPort.objects.create(device=self.device, name='Front Port 4'), ] - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=front_ports[0], front_port_position=1, rear_port=rear_ports[0], rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=front_ports[1], front_port_position=1, rear_port=rear_ports[1], rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=front_ports[2], front_port_position=1, rear_port=rear_ports[2], rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=front_ports[3], front_port_position=1, rear_port=rear_ports[3], rear_port_position=1, ), ]) @@ -748,11 +748,11 @@ class CablePathTests(CablePathTestCase): rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') - PortAssignment.objects.bulk_create([ - PortAssignment( + PortMapping.objects.bulk_create([ + PortMapping( front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, ), - PortAssignment( + PortMapping( front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, ), ]) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1b296766e..7b83cfbbe 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1360,9 +1360,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPortTemplate(device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), ) FrontPortTemplate.objects.bulk_create(front_ports) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), ]) ModuleBayTemplate.objects.bulk_create(( ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'), @@ -1624,9 +1624,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPortTemplate(module_type=module_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), ) FrontPortTemplate.objects.bulk_create(front_ports) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), ]) def test_q(self): @@ -2067,10 +2067,10 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ), ) FrontPortTemplate.objects.bulk_create(front_ports) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), ]) def test_name(self): @@ -2746,9 +2746,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C), ) FrontPort.objects.bulk_create(front_ports) - PortAssignment.objects.bulk_create([ - PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping.objects.bulk_create([ + PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), ]) ModuleBay.objects.create(device=devices[0], name='Module Bay 1') ModuleBay.objects.create(device=devices[1], name='Module Bay 2') @@ -5142,13 +5142,13 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil ), ) FrontPort.objects.bulk_create(front_ports) - PortAssignment.objects.bulk_create([ - PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1], rear_port_position=2), - PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2], rear_port_position=3), - PortAssignment(front_port=front_ports[3], rear_port=rear_ports[3]), - PortAssignment(front_port=front_ports[4], rear_port=rear_ports[4]), - PortAssignment(front_port=front_ports[5], rear_port=rear_ports[5]), + PortMapping.objects.bulk_create([ + PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(front_port=front_ports[1], rear_port=rear_ports[1], rear_port_position=2), + PortMapping(front_port=front_ports[2], rear_port=rear_ports[2], rear_port_position=3), + PortMapping(front_port=front_ports[3], rear_port=rear_ports[3]), + PortMapping(front_port=front_ports[4], rear_port=rear_ports[4]), + PortMapping(front_port=front_ports[5], rear_port=rear_ports[5]), ]) # Cables @@ -6412,7 +6412,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): power_outlet = PowerOutlet.objects.create(device=devices[0], name='Power Outlet 1') rear_port = RearPort.objects.create(device=devices[0], name='Rear Port 1') front_port = FrontPort.objects.create(device=devices[0], name='Front Port 1') - PortAssignment.objects.create(front_port=front_port, rear_port=rear_port) + PortMapping.objects.create(front_port=front_port, rear_port=rear_port) power_panel = PowerPanel.objects.create(name='Power Panel 1', site=sites[0]) power_feed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index dabd076d9..cd5ff0936 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -451,7 +451,7 @@ class DeviceTestCase(TestCase): ) frontport.save() - PortAssignmentTemplate.objects.create( + PortTemplateMapping.objects.create( front_port=frontport, rear_port=rearport, rear_port_position=2, @@ -845,11 +845,11 @@ class CableTestCase(TestCase): FrontPort(device=patch_panel, name='FP4', type='8p8c'), ) FrontPort.objects.bulk_create(front_ports) - PortAssignment.objects.bulk_create([ - PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), - PortAssignment(front_port=front_ports[3], rear_port=rear_ports[3]), + PortMapping.objects.bulk_create([ + PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping(front_port=front_ports[3], rear_port=rear_ports[3]), ]) provider = Provider.objects.create(name='Provider 1', slug='provider-1') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 3a0dc3ca2..6b179cea8 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -746,10 +746,10 @@ class DeviceTypeTestCase( FrontPortTemplate(device_type=devicetype, name='Front Port 3'), ) FrontPortTemplate.objects.bulk_create(front_ports) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), ]) url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk}) @@ -1318,10 +1318,10 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): FrontPortTemplate(module_type=moduletype, name='Front Port 3'), ) FrontPortTemplate.objects.bulk_create(front_ports) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), ]) url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk}) @@ -1776,10 +1776,10 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas FrontPortTemplate(device_type=devicetype, name='Front Port Template 3'), ) FrontPortTemplate.objects.bulk_create(front_ports) - PortAssignmentTemplate.objects.bulk_create([ - PortAssignmentTemplate(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignmentTemplate(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignmentTemplate(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping.objects.bulk_create([ + PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), ]) cls.form_data = { @@ -2270,10 +2270,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): FrontPort(device=device, name='Front Port Template 3'), ) FrontPort.objects.bulk_create(front_ports) - PortAssignment.objects.bulk_create([ - PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping.objects.bulk_create([ + PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), ]) url = reverse('dcim:device_frontports', kwargs={'pk': device.pk}) @@ -3075,10 +3075,10 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): FrontPort(device=device, name='Front Port 3'), ) FrontPort.objects.bulk_create(front_ports) - PortAssignment.objects.bulk_create([ - PortAssignment(front_port=front_ports[0], rear_port=rear_ports[0]), - PortAssignment(front_port=front_ports[1], rear_port=rear_ports[1]), - PortAssignment(front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping.objects.bulk_create([ + PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 50963890f..625003843 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -85,18 +85,17 @@ def update_interface_bridges(device, interface_templates, module=None): interface.save() -def create_port_assignments(device, templates, module=None): +def create_port_mappings(device, templates, module=None): """ - Used for device and module instantiation. Replicate all front/rear port assignments from a DeviceType to the given - device. + Replicate all front/rear port mappings from a DeviceType to the given device. """ - from dcim.models.device_components import FrontPort, PortAssignment, RearPort + from dcim.models.device_components import FrontPort, PortMapping, RearPort for template in templates: front_port = FrontPort.objects.get(device=device, name=template.front_port.resolve_name(module=module)) rear_port = RearPort.objects.get(device=device, name=template.rear_port.resolve_name(module=module)) - assignment = PortAssignment( + assignment = PortMapping( front_port=front_port, front_port_position=template.front_port_position, rear_port=rear_port, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a3d0b2ded..ca2f11537 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -42,7 +42,7 @@ from wireless.models import WirelessLAN from . import filtersets, forms, tables from .choices import DeviceFaceChoices, InterfaceModeChoices from .models import * -from .models.device_components import PortAssignment +from .models.device_components import PortMapping from .object_actions import BulkAddComponents, BulkDisconnect CABLE_TERMINATION_TYPES = { @@ -3245,7 +3245,7 @@ class FrontPortView(generic.ObjectView): def get_extra_context(self, request, instance): return { - 'rear_port_assignments': PortAssignment.objects.filter(front_port=instance).prefetch_related('rear_port'), + 'rear_port_mappings': PortMapping.objects.filter(front_port=instance).prefetch_related('rear_port'), } @@ -3321,7 +3321,7 @@ class RearPortView(generic.ObjectView): def get_extra_context(self, request, instance): return { - 'front_port_assignments': PortAssignment.objects.filter(rear_port=instance).prefetch_related('front_port'), + 'front_port_mappings': PortMapping.objects.filter(rear_port=instance).prefetch_related('front_port'), } diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 180c6c3e2..08a3a8d2f 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -64,11 +64,11 @@

{% trans "Rear Ports" %}

- {% for assignment in rear_port_assignments %} + {% for mapping in rear_port_mappings %} - - - + + + {% endfor %}
{{ assignment.front_port_position }}{{ assignment.rear_port|linkify }}{{ assignment.rear_port_position }}{{ mapping.front_port_position }}{{ mapping.rear_port|linkify }}{{ mapping.rear_port_position }}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 6b8236218..98cb70851 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -64,11 +64,11 @@

{% trans "Rear Ports" %}

- {% for assignment in front_port_assignments %} + {% for mapping in front_port_mappings %} - - - + + + {% endfor %}
{{ assignment.rear_port_position }}{{ assignment.front_port|linkify }}{{ assignment.front_port_position }}{{ mapping.rear_port_position }}{{ mapping.front_port|linkify }}{{ mapping.front_port_position }}
From 7c2193685e757e306c13ca8260b768703d322e2a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 10:21:15 -0500 Subject: [PATCH 35/51] Consolidate create() and update() logic into PortSerializer base class --- netbox/dcim/api/serializers_/base.py | 52 +++++++++++++++++++ .../api/serializers_/device_components.py | 50 ++---------------- .../api/serializers_/devicetype_components.py | 49 ++--------------- 3 files changed, 58 insertions(+), 93 deletions(-) diff --git a/netbox/dcim/api/serializers_/base.py b/netbox/dcim/api/serializers_/base.py index 1dca773b2..3b9142f1d 100644 --- a/netbox/dcim/api/serializers_/base.py +++ b/netbox/dcim/api/serializers_/base.py @@ -2,10 +2,12 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate from utilities.api import get_serializer_for_model __all__ = ( 'ConnectedEndpointsSerializer', + 'PortSerializer', ) @@ -35,3 +37,53 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer): @extend_schema_field(serializers.BooleanField) def get_connected_endpoints_reachable(self, obj): return obj._path and obj._path.is_complete and obj._path.is_active + + +class PortSerializer(serializers.ModelSerializer): + """ + Base serializer for front & rear port and port templates. + """ + @property + def _mapper(self): + """ + Return the model and ForeignKey field name used to track port mappings for this model. + """ + if self.Meta.model is FrontPort: + return PortMapping, 'front_port' + if self.Meta.model is RearPort: + return PortMapping, 'rear_port' + if self.Meta.model is FrontPortTemplate: + return PortTemplateMapping, 'front_port' + if self.Meta.model is RearPortTemplate: + return PortTemplateMapping, 'rear_port' + raise ValueError(f"Could not determine mapping details for {self.__class__}") + + def create(self, validated_data): + mappings = validated_data.pop('mappings', []) + instance = super().create(validated_data) + + # Create port mappings + mapping_model, fk_name = self._mapper + for attrs in mappings: + mapping_model.objects.create(**{ + fk_name: instance, + **attrs, + }) + + return instance + + def update(self, instance, validated_data): + mappings = validated_data.pop('mappings', None) + instance = super().update(instance, validated_data) + + if mappings is not None: + # Update port mappings + mapping_model, fk_name = self._mapper + mapping_model.objects.filter(**{fk_name: instance}).delete() + for attrs in mappings: + mapping_model.objects.create(**{ + fk_name: instance, + **attrs, + }) + + return instance diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 8d7f3be6f..99940b942 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -19,7 +19,7 @@ from wireless.api.serializers_.nested import NestedWirelessLinkSerializer from wireless.api.serializers_.wirelesslans import WirelessLANSerializer from wireless.choices import * from wireless.models import WirelessLAN -from .base import ConnectedEndpointsSerializer +from .base import ConnectedEndpointsSerializer, PortSerializer from .cables import CabledObjectSerializer from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer from .manufacturers import ManufacturerSerializer @@ -307,7 +307,7 @@ class RearPortMappingSerializer(serializers.ModelSerializer): fields = ('position', 'front_port', 'front_port_position') -class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): +class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer): device = DeviceSerializer(nested=True) module = ModuleSerializer( nested=True, @@ -331,28 +331,6 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') - def create(self, validated_data): - mappings = validated_data.pop('mappings', []) - instance = super().create(validated_data) - - # Create FrontPort mappings - for attrs in mappings: - PortMapping.objects.create(rear_port=instance, **attrs) - - return instance - - def update(self, instance, validated_data): - mappings = validated_data.pop('mappings', None) - instance = super().update(instance, validated_data) - - if mappings is not None: - # Update FrontPort mappings - PortMapping.objects.filter(rear_port=instance).delete() - for attrs in mappings: - PortMapping.objects.create(rear_port=instance, **attrs) - - return instance - class FrontPortMappingSerializer(serializers.ModelSerializer): position = serializers.IntegerField( @@ -367,7 +345,7 @@ class FrontPortMappingSerializer(serializers.ModelSerializer): fields = ('position', 'rear_port', 'rear_port_position') -class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): +class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer): device = DeviceSerializer(nested=True) module = ModuleSerializer( nested=True, @@ -391,28 +369,6 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') - def create(self, validated_data): - mappings = validated_data.pop('mappings', []) - instance = super().create(validated_data) - - # Create RearPort mappings - for attrs in mappings: - PortMapping.objects.create(front_port=instance, **attrs) - - return instance - - def update(self, instance, validated_data): - mappings = validated_data.pop('mappings', None) - instance = super().update(instance, validated_data) - - if mappings is not None: - # Update RearPort mappings - PortMapping.objects.filter(front_port=instance).delete() - for attrs in mappings: - PortMapping.objects.create(front_port=instance, **attrs) - - return instance - class ModuleBaySerializer(NetBoxModelSerializer): device = DeviceSerializer(nested=True) diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 05bcff776..9a9b5d470 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -12,6 +12,7 @@ from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.gfk_fields import GFKSerializerField from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from wireless.choices import * +from .base import PortSerializer from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer from .manufacturers import ManufacturerSerializer from .nested import NestedInterfaceTemplateSerializer @@ -219,7 +220,7 @@ class RearPortTemplateMappingSerializer(serializers.ModelSerializer): fields = ('position', 'front_port', 'front_port_position') -class RearPortTemplateSerializer(ComponentTemplateSerializer): +class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer): device_type = DeviceTypeSerializer( required=False, nested=True, @@ -247,28 +248,6 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer): ] brief_fields = ('id', 'url', 'display', 'name', 'description') - def create(self, validated_data): - mappings = validated_data.pop('mappings', []) - instance = super().create(validated_data) - - # Create FrontPort mappings - for attrs in mappings: - PortTemplateMapping.objects.create(rear_port=instance, **attrs) - - return instance - - def update(self, instance, validated_data): - mappings = validated_data.pop('mappings', None) - instance = super().update(instance, validated_data) - - if mappings is not None: - # Update FrontPort mappings - PortTemplateMapping.objects.filter(rear_port=instance).delete() - for attrs in mappings: - PortTemplateMapping.objects.create(rear_port=instance, **attrs) - - return instance - class FrontPortTemplateMappingSerializer(serializers.ModelSerializer): position = serializers.IntegerField( @@ -283,7 +262,7 @@ class FrontPortTemplateMappingSerializer(serializers.ModelSerializer): fields = ('position', 'rear_port', 'rear_port_position') -class FrontPortTemplateSerializer(ComponentTemplateSerializer): +class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -311,28 +290,6 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer): ] brief_fields = ('id', 'url', 'display', 'name', 'description') - def create(self, validated_data): - mappings = validated_data.pop('mappings', []) - instance = super().create(validated_data) - - # Create RearPort mappings - for attrs in mappings: - PortTemplateMapping.objects.create(front_port=instance, **attrs) - - return instance - - def update(self, instance, validated_data): - mappings = validated_data.pop('mappings', None) - instance = super().update(instance, validated_data) - - if mappings is not None: - # Update RearPort assignments - PortTemplateMapping.objects.filter(front_port=instance).delete() - for attrs in mappings: - PortTemplateMapping.objects.create(front_port=instance, **attrs) - - return instance - class ModuleBayTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( From ca880218d9e6affeae3d0463640baa0a27825bc3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 10:32:49 -0500 Subject: [PATCH 36/51] Validate position count on FrontPort & FrontPortTemplate --- netbox/dcim/models/device_component_templates.py | 15 ++++++++++++++- netbox/dcim/models/device_components.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 090ae2c08..1aa3326dc 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -585,6 +585,19 @@ class FrontPortTemplate(ModularComponentTemplateModel): verbose_name = _('front port template') verbose_name_plural = _('front port templates') + def clean(self): + super().clean() + + # Check that positions is greater than or equal to the number of associated RearPortTemplates + if not self._state.adding: + mapping_count = self.mappings.count() + if self.positions < mapping_count: + raise ValidationError({ + "positions": _( + "The number of positions cannot be less than the number of mapped rear port templates ({count})" + ).format(count=mapping_count) + }) + def instantiate(self, **kwargs): return self.component_model( name=self.resolve_name(kwargs.get('module')), @@ -638,7 +651,7 @@ class RearPortTemplate(ModularComponentTemplateModel): def clean(self): super().clean() - # Check that positions count is greater than or equal to the number of associated FrontPortTemplates + # Check that positions is greater than or equal to the number of associated FrontPortTemplates if not self._state.adding: mapping_count = self.mappings.count() if self.positions < mapping_count: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index bc58c1ead..6eefd6ef1 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1132,6 +1132,19 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): verbose_name = _('front port') verbose_name_plural = _('front ports') + def clean(self): + super().clean() + + # Check that positions is greater than or equal to the number of associated RearPorts + if not self._state.adding: + mapping_count = self.mappings.count() + if self.positions < mapping_count: + raise ValidationError({ + "positions": _( + "The number of positions cannot be less than the number of mapped rear ports ({count})" + ).format(count=mapping_count) + }) + class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): """ From 06447ac637b254e9b9c1f27ad8decbe5a7615aa6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 11:48:50 -0500 Subject: [PATCH 37/51] Optimize replication of port mappings from DeviceType --- netbox/dcim/models/devices.py | 13 ++++------- netbox/dcim/tests/test_models.py | 2 ++ netbox/dcim/utils.py | 37 ++++++++++++++++++++++---------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2679b7296..423265751 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,8 +1,7 @@ import decimal -import yaml - from functools import cached_property +import yaml from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -25,17 +24,15 @@ from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices from netbox.config import ConfigItem from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel -from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin +from netbox.models.mixins import WeightMixin from utilities.fields import ColorField, CounterCacheField from utilities.prefetch import get_prefetchable_fields from utilities.tracking import TrackingModelMixin -from . import PortTemplateMapping from .device_components import * from .mixins import RenderConfigMixin from .modules import Module - __all__ = ( 'Device', 'DeviceRole', @@ -1004,6 +1001,8 @@ class Device( self._instantiate_components(self.device_type.interfacetemplates.all()) self._instantiate_components(self.device_type.rearporttemplates.all()) self._instantiate_components(self.device_type.frontporttemplates.all()) + # Replicate any front/rear port mappings from the DeviceType + create_port_mappings(self, self.device_type) # Disable bulk_create to accommodate MPTT self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False) self._instantiate_components(self.device_type.devicebaytemplates.all()) @@ -1011,10 +1010,6 @@ class Device( self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) # Interface bridges have to be set after interface instantiation update_interface_bridges(self, self.device_type.interfacetemplates.all()) - # Replicate any front/rear port mappings from the DeviceType - create_port_mappings(self, PortTemplateMapping.objects.filter( - front_port__device_type=self.device_type - )) # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index cd5ff0936..1f332ed21 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -537,6 +537,8 @@ class DeviceTestCase(TestCase): ) self.assertEqual(frontport.cf['cf1'], 'foo') + self.assertTrue(PortMapping.objects.filter(front_port=frontport, rear_port=rearport).exists()) + modulebay = ModuleBay.objects.get( device=device, name='Module Bay 1' diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 625003843..f5b94829e 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -85,20 +85,35 @@ def update_interface_bridges(device, interface_templates, module=None): interface.save() -def create_port_mappings(device, templates, module=None): +def create_port_mappings(device, device_type, module=None): """ Replicate all front/rear port mappings from a DeviceType to the given device. """ - from dcim.models.device_components import FrontPort, PortMapping, RearPort + from dcim.models import FrontPort, PortMapping, PortTemplateMapping, RearPort + templates = PortTemplateMapping.objects.filter( + front_port__device_type=device_type + ).prefetch_related('front_port', 'rear_port') + + # Cache front & rear ports for efficient lookups by name + front_ports = { + fp.name: fp for fp in FrontPort.objects.filter(device=device) + } + rear_ports = { + rp.name: rp for rp in RearPort.objects.filter(device=device) + } + + # Replicate PortMappings + mappings = [] for template in templates: - front_port = FrontPort.objects.get(device=device, name=template.front_port.resolve_name(module=module)) - rear_port = RearPort.objects.get(device=device, name=template.rear_port.resolve_name(module=module)) - - assignment = PortMapping( - front_port=front_port, - front_port_position=template.front_port_position, - rear_port=rear_port, - rear_port_position=template.rear_port_position, + front_port = front_ports.get(template.front_port.resolve_name(module=module)) + rear_port = rear_ports.get(template.rear_port.resolve_name(module=module)) + mappings.append( + PortMapping( + front_port=front_port, + front_port_position=template.front_port_position, + rear_port=rear_port, + rear_port_position=template.rear_port_position, + ) ) - assignment.save() + PortMapping.objects.bulk_create(mappings) From e6b1f942cd02ecced6df62a8876e0f0978bf6887 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 12:20:40 -0500 Subject: [PATCH 38/51] Misc cleanup --- netbox/dcim/forms/mixins.py | 11 +++-------- netbox/dcim/forms/model_forms.py | 3 +-- netbox/dcim/signals.py | 1 + netbox/dcim/tables/devices.py | 2 +- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index db9252b07..0497cd1ed 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -4,7 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.constants import LOCATION_SCOPE_TYPES -from dcim.models import PortTemplateMapping, Site +from dcim.models import PortMapping, Site from utilities.forms import get_field_value from utilities.forms.fields import ( ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField, @@ -138,17 +138,12 @@ class FrontPortFormMixin(forms.Form): widget=forms.SelectMultiple(attrs={'size': 8}) ) - port_mapping_model = PortTemplateMapping + port_mapping_model = PortMapping + parent_field = 'device' def clean(self): super().clean() - # FrontPort with no positions cannot be mapped to more than one RearPort - if not self.cleaned_data['positions'] and len(self.cleaned_data['rear_ports']) > 1: - raise forms.ValidationError({ - 'positions': _("A front port with no positions cannot be mapped to multiple rear ports.") - }) - # Count of selected rear port & position pairs much match the assigned number of positions if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']: raise forms.ValidationError({ diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 8471a7163..e21cad38e 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1124,6 +1124,7 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): ), ) + # Override FrontPortFormMixin attrs port_mapping_model = PortTemplateMapping parent_field = 'device_type' @@ -1620,8 +1621,6 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): ), ) - port_mapping_model = PortMapping - class Meta: model = FrontPort fields = [ diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index d9edaaa98..81cb0ae6c 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -150,6 +150,7 @@ def nullify_connected_endpoints(instance, **kwargs): cablepath.retrace() +# TODO: Adapt signal handler to act on changes to port mappings @receiver(post_save, sender=FrontPort) def extend_rearport_cable_paths(instance, created, raw, **kwargs): """ diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 2b5a3eeff..d81718d78 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -840,7 +840,7 @@ class DeviceRearPortTable(RearPortTable): 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'mappings', 'description', 'cable', 'link_peer', + 'pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'cable', 'link_peer', ) From 006407f7e44b27a0216420794d4cda1d3846ace3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 14:09:48 -0500 Subject: [PATCH 39/51] get_related_models() should ignore models marked as private --- netbox/utilities/relations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/relations.py b/netbox/utilities/relations.py index d5e88299c..3514d394d 100644 --- a/netbox/utilities/relations.py +++ b/netbox/utilities/relations.py @@ -13,7 +13,7 @@ def get_related_models(model, ordered=True): related_models = [ (field.related_model, field.remote_field.name) for field in model._meta.related_objects - if type(field) is ManyToOneRel + if type(field) is ManyToOneRel and not getattr(field.related_model, '_netbox_private', False) ] if ordered: From 836ddbf49dd0ae1f606372bb3601eb52594a5672 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 14:11:08 -0500 Subject: [PATCH 40/51] Add FKs from PortMapping & PortTemplateMapping to their parent models --- netbox/dcim/forms/mixins.py | 24 +- netbox/dcim/forms/model_forms.py | 10 +- netbox/dcim/migrations/0222_port_mappings.py | 31 ++ netbox/dcim/models/base.py | 2 + .../dcim/models/device_component_templates.py | 20 + netbox/dcim/models/device_components.py | 10 + netbox/dcim/tests/test_api.py | 44 +- netbox/dcim/tests/test_cablepaths.py | 422 +++++++++++++++--- netbox/dcim/tests/test_cablepaths2.py | 66 ++- netbox/dcim/tests/test_filtersets.py | 32 +- netbox/dcim/tests/test_models.py | 9 +- netbox/dcim/tests/test_views.py | 30 +- netbox/dcim/utils.py | 7 +- 13 files changed, 564 insertions(+), 143 deletions(-) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 0497cd1ed..98d2117b6 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -4,7 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.constants import LOCATION_SCOPE_TYPES -from dcim.models import PortMapping, Site +from dcim.models import PortMapping, PortTemplateMapping, Site from utilities.forms import get_field_value from utilities.forms.fields import ( ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField, @@ -161,14 +161,24 @@ class FrontPortFormMixin(forms.Form): # Create new rear port mappings mappings = [] + if self.port_mapping_model is PortTemplateMapping: + params = { + 'device_type_id': self.instance.device_type_id, + 'module_type_id': self.instance.module_type_id, + } + else: + params = { + 'device_id': self.instance.device_id, + } for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1): rear_port_id, rear_port_position = rp_position.split(':') mappings.append( - self.port_mapping_model( - front_port_id=self.instance.pk, - front_port_position=i, - rear_port_id=rear_port_id, - rear_port_position=rear_port_position, - ) + self.port_mapping_model(**{ + **params, + 'front_port_id': self.instance.pk, + 'front_port_position': i, + 'rear_port_id': rear_port_id, + 'rear_port_position': rear_port_position, + }) ) self.port_mapping_model.objects.bulk_create(mappings) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index e21cad38e..709ecdfe0 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1158,10 +1158,8 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): those assigned to the specified instance. """ occupied_rear_port_positions = [ - f'{assignment.rear_port_id}:{assignment.rear_port_position}' - for assignment in PortTemplateMapping.objects.filter( - front_port__device_type=device_type - ).exclude(front_port=front_port.pk) + f'{mapping.rear_port_id}:{mapping.rear_port_position}' + for mapping in device_type.port_mappings.exclude(front_port=front_port.pk) ] choices = [] @@ -1652,8 +1650,8 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): assigned to the specified instance. """ occupied_rear_port_positions = [ - f'{assignment.rear_port_id}:{assignment.rear_port_position}' - for assignment in PortMapping.objects.filter(front_port__device=device).exclude(front_port=front_port.pk) + f'{mapping.rear_port_id}:{mapping.rear_port_position}' + for mapping in device.port_mappings.exclude(front_port=front_port.pk) ] choices = [] diff --git a/netbox/dcim/migrations/0222_port_mappings.py b/netbox/dcim/migrations/0222_port_mappings.py index 7978ca288..a163ae18d 100644 --- a/netbox/dcim/migrations/0222_port_mappings.py +++ b/netbox/dcim/migrations/0222_port_mappings.py @@ -23,6 +23,8 @@ def populate_port_template_mappings(apps, schema_editor): def generate_copies(): for front_port in front_ports: yield PortTemplateMapping( + device_type_id=front_port.device_type_id, + module_type_id=front_port.module_type_id, front_port_id=front_port.pk, front_port_position=1, rear_port_id=front_port.rear_port_id, @@ -43,6 +45,7 @@ def populate_port_mappings(apps, schema_editor): def generate_copies(): for front_port in front_ports: yield PortMapping( + device_id=front_port.device_id, front_port_id=front_port.pk, front_port_position=1, rear_port_id=front_port.rear_port_id, @@ -85,6 +88,26 @@ class Migration(migrations.Migration): ] ) ), + ( + 'device_type', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='dcim.devicetype', + related_name='port_mappings', + blank=True, + null=True + ) + ), + ( + 'module_type', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='dcim.moduletype', + related_name='port_mappings', + blank=True, + null=True + ) + ), ( 'front_port', models.ForeignKey( @@ -143,6 +166,14 @@ class Migration(migrations.Migration): ] ), ), + ( + 'device', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='dcim.device', + related_name='port_mappings' + ) + ), ( 'front_port', models.ForeignKey( diff --git a/netbox/dcim/models/base.py b/netbox/dcim/models/base.py index f75ddfefa..f8021d4db 100644 --- a/netbox/dcim/models/base.py +++ b/netbox/dcim/models/base.py @@ -29,6 +29,8 @@ class PortMappingBase(models.Model): ), ) + _netbox_private = True + class Meta: abstract = True constraints = ( diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 1aa3326dc..16e281523 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -524,6 +524,20 @@ class PortTemplateMapping(PortMappingBase): """ Maps a FrontPortTemplate & position to a RearPortTemplate & position. """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='port_mappings', + blank=True, + null=True, + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.CASCADE, + related_name='port_mappings', + blank=True, + null=True, + ) front_port = models.ForeignKey( to='dcim.FrontPortTemplate', on_delete=models.CASCADE, @@ -546,6 +560,12 @@ class PortTemplateMapping(PortMappingBase): ) }) + def save(self, *args, **kwargs): + # Associate the mapping with the parent DeviceType/ModuleType + self.device_type = self.front_port.device_type + self.module_type = self.front_port.module_type + super().save(*args, **kwargs) + class FrontPortTemplate(ModularComponentTemplateModel): """ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 6eefd6ef1..b629813de 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1075,6 +1075,11 @@ class PortMapping(PortMappingBase): """ Maps a FrontPort & position to a RearPort & position. """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='port_mappings', + ) front_port = models.ForeignKey( to='dcim.FrontPort', on_delete=models.CASCADE, @@ -1097,6 +1102,11 @@ class PortMapping(PortMappingBase): ) }) + def save(self, *args, **kwargs): + # Associate the mapping with the parent Device + self.device = self.front_port.device + super().save(*args, **kwargs) + class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 7f17b7683..419bac20f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -984,9 +984,21 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): ) FrontPortTemplate.objects.bulk_create(front_port_templates) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), - PortTemplateMapping(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), - PortTemplateMapping(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), + PortTemplateMapping( + device_type=devicetype, + front_port=front_port_templates[0], + rear_port=rear_port_templates[0], + ), + PortTemplateMapping( + device_type=devicetype, + front_port=front_port_templates[1], + rear_port=rear_port_templates[1], + ), + PortTemplateMapping( + module_type=moduletype, + front_port=front_port_templates[2], + rear_port=rear_port_templates[2], + ), ]) cls.create_data = [ @@ -1062,9 +1074,21 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): ) RearPortTemplate.objects.bulk_create(rear_port_templates) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_port_templates[0], rear_port=rear_port_templates[0]), - PortTemplateMapping(front_port=front_port_templates[1], rear_port=rear_port_templates[1]), - PortTemplateMapping(front_port=front_port_templates[2], rear_port=rear_port_templates[2]), + PortTemplateMapping( + device_type=devicetype, + front_port=front_port_templates[0], + rear_port=rear_port_templates[0], + ), + PortTemplateMapping( + device_type=devicetype, + front_port=front_port_templates[1], + rear_port=rear_port_templates[1], + ), + PortTemplateMapping( + module_type=moduletype, + front_port=front_port_templates[2], + rear_port=rear_port_templates[2], + ), ]) cls.create_data = [ @@ -2041,9 +2065,9 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): ) FrontPort.objects.bulk_create(front_ports) PortMapping.objects.bulk_create([ - PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping(device=device, front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(device=device, front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(device=device, front_port=front_ports[2], rear_port=rear_ports[2]), ]) cls.create_data = [ @@ -2091,7 +2115,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): interface1 = Interface.objects.create(device=device, name='Interface 1') rear_port = RearPort.objects.create(device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C) front_port = FrontPort.objects.create(device=device, name='Front Port 10', type=PortTypeChoices.TYPE_8P8C) - PortMapping.objects.create(front_port=front_port, rear_port=rear_port) + PortMapping.objects.create(device=device, front_port=front_port, rear_port=rear_port) Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port]) self.add_permissions(f'dcim.view_{self.model._meta.model_name}') diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index bbedd856e..1bd613e3b 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -284,6 +284,7 @@ class LegacyCablePathTests(CablePathTestCase): rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortMapping.objects.create( + device=self.device, front_port=frontport1, front_port_position=1, rear_port=rearport1, @@ -347,6 +348,7 @@ class LegacyCablePathTests(CablePathTestCase): rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortMapping.objects.create( + device=self.device, front_port=frontport1, front_port_position=1, rear_port=rearport1, @@ -417,16 +419,32 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2_1, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + device=self.device, + front_port=frontport2_2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=2, ), ]) @@ -541,16 +559,32 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2_1, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + device=self.device, + front_port=frontport2_2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=2, ), ]) @@ -711,22 +745,46 @@ class LegacyCablePathTests(CablePathTestCase): frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, + device=self.device, + front_port=frontport4_1, + front_port_position=1, + rear_port=rearport4, + rear_port_position=1, ), PortMapping( - front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, + device=self.device, + front_port=frontport4_2, + front_port_position=1, + rear_port=rearport4, + rear_port_position=2, ), ]) @@ -839,28 +897,60 @@ class LegacyCablePathTests(CablePathTestCase): frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2_1, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + device=self.device, + front_port=frontport2_2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=2, ), PortMapping( - front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3_1, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, + device=self.device, + front_port=frontport3_2, + front_port_position=1, + rear_port=rearport3, + rear_port_position=2, ), PortMapping( - front_port=frontport4_1, front_port_position=1, rear_port=rearport4, rear_port_position=1, + device=self.device, + front_port=frontport4_1, + front_port_position=1, + rear_port=rearport4, + rear_port_position=1, ), PortMapping( - front_port=frontport4_2, front_port_position=1, rear_port=rearport4, rear_port_position=2, + device=self.device, + front_port=frontport4_2, + front_port_position=1, + rear_port=rearport4, + rear_port_position=2, ), ]) @@ -975,19 +1065,39 @@ class LegacyCablePathTests(CablePathTestCase): frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport3_1, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3_1, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport3_2, front_port_position=1, rear_port=rearport3, rear_port_position=2, + device=self.device, + front_port=frontport3_2, + front_port_position=1, + rear_port=rearport3, + rear_port_position=2, ), ]) @@ -1082,10 +1192,18 @@ class LegacyCablePathTests(CablePathTestCase): frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), ]) @@ -1469,16 +1587,32 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2_1, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + device=self.device, + front_port=frontport2_2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=2, ), ]) circuittermination1 = CircuitTermination.objects.create( @@ -1667,16 +1801,32 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + device=self.device, + front_port=frontport4, + front_port_position=1, + rear_port=rearport4, + rear_port_position=1, ), ]) @@ -1760,28 +1910,60 @@ class LegacyCablePathTests(CablePathTestCase): frontport2_4 = FrontPort.objects.create(device=self.device, name='Front Port 2:4') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport1_3, front_port_position=1, rear_port=rearport1, rear_port_position=3, + device=self.device, + front_port=frontport1_3, + front_port_position=1, + rear_port=rearport1, + rear_port_position=3, ), PortMapping( - front_port=frontport1_4, front_port_position=1, rear_port=rearport1, rear_port_position=4, + device=self.device, + front_port=frontport1_4, + front_port_position=1, + rear_port=rearport1, + rear_port_position=4, ), PortMapping( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2_1, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + device=self.device, + front_port=frontport2_2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=2, ), PortMapping( - front_port=frontport2_3, front_port_position=1, rear_port=rearport2, rear_port_position=3, + device=self.device, + front_port=frontport2_3, + front_port_position=1, + rear_port=rearport2, + rear_port_position=3, ), PortMapping( - front_port=frontport2_4, front_port_position=1, rear_port=rearport2, rear_port_position=4, + device=self.device, + front_port=frontport2_4, + front_port_position=1, + rear_port=rearport2, + rear_port_position=4, ), ]) @@ -1940,16 +2122,32 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + device=self.device, + front_port=frontport4, + front_port_position=1, + rear_port=rearport4, + rear_port_position=1, ), ]) @@ -2025,16 +2223,32 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + device=self.device, + front_port=frontport4, + front_port_position=1, + rear_port=rearport4, + rear_port_position=1, ), ]) @@ -2131,22 +2345,46 @@ class LegacyCablePathTests(CablePathTestCase): frontport6 = FrontPort.objects.create(device=self.device, name='Front Port 6') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + device=self.device, + front_port=frontport4, + front_port_position=1, + rear_port=rearport4, + rear_port_position=1, ), PortMapping( - front_port=frontport5, front_port_position=1, rear_port=rearport5, rear_port_position=1, + device=self.device, + front_port=frontport5, + front_port_position=1, + rear_port=rearport5, + rear_port_position=1, ), PortMapping( - front_port=frontport6, front_port_position=1, rear_port=rearport6, rear_port_position=1, + device=self.device, + front_port=frontport6, + front_port_position=1, + rear_port=rearport6, + rear_port_position=1, ), ]) @@ -2253,10 +2491,18 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), ]) @@ -2376,10 +2622,18 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), ]) @@ -2426,10 +2680,18 @@ class LegacyCablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), ]) @@ -2481,7 +2743,11 @@ class LegacyCablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), ]) @@ -2595,16 +2861,32 @@ class LegacyCablePathTests(CablePathTestCase): frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport3, front_port_position=1, rear_port=rearport3, rear_port_position=1, + device=self.device, + front_port=frontport3, + front_port_position=1, + rear_port=rearport3, + rear_port_position=1, ), PortMapping( - front_port=frontport4, front_port_position=1, rear_port=rearport4, rear_port_position=1, + device=self.device, + front_port=frontport4, + front_port_position=1, + rear_port=rearport4, + rear_port_position=1, ), ]) diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 3a12d755e..66ab58257 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -365,7 +365,11 @@ class CablePathTests(CablePathTestCase): frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), ]) @@ -443,16 +447,32 @@ class CablePathTests(CablePathTestCase): frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1_1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1_1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport1_2, front_port_position=1, rear_port=rearport1, rear_port_position=2, + device=self.device, + front_port=frontport1_2, + front_port_position=1, + rear_port=rearport1, + rear_port_position=2, ), PortMapping( - front_port=frontport2_1, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2_1, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), PortMapping( - front_port=frontport2_2, front_port_position=1, rear_port=rearport2, rear_port_position=2, + device=self.device, + front_port=frontport2_2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=2, ), ]) @@ -671,16 +691,32 @@ class CablePathTests(CablePathTestCase): ] PortMapping.objects.bulk_create([ PortMapping( - front_port=front_ports[0], front_port_position=1, rear_port=rear_ports[0], rear_port_position=1, + device=self.device, + front_port=front_ports[0], + front_port_position=1, + rear_port=rear_ports[0], + rear_port_position=1, ), PortMapping( - front_port=front_ports[1], front_port_position=1, rear_port=rear_ports[1], rear_port_position=1, + device=self.device, + front_port=front_ports[1], + front_port_position=1, + rear_port=rear_ports[1], + rear_port_position=1, ), PortMapping( - front_port=front_ports[2], front_port_position=1, rear_port=rear_ports[2], rear_port_position=1, + device=self.device, + front_port=front_ports[2], + front_port_position=1, + rear_port=rear_ports[2], + rear_port_position=1, ), PortMapping( - front_port=front_ports[3], front_port_position=1, rear_port=rear_ports[3], rear_port_position=1, + device=self.device, + front_port=front_ports[3], + front_port_position=1, + rear_port=rear_ports[3], + rear_port_position=1, ), ]) @@ -750,10 +786,18 @@ class CablePathTests(CablePathTestCase): frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') PortMapping.objects.bulk_create([ PortMapping( - front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1, + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, ), PortMapping( - front_port=frontport2, front_port_position=1, rear_port=rearport2, rear_port_position=1, + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, ), ]) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 7b83cfbbe..296f6d433 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1361,8 +1361,8 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): ) FrontPortTemplate.objects.bulk_create(front_ports) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(device_type=device_types[0], front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(device_type=device_types[1], front_port=front_ports[1], rear_port=rear_ports[1]), ]) ModuleBayTemplate.objects.bulk_create(( ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'), @@ -1625,8 +1625,8 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): ) FrontPortTemplate.objects.bulk_create(front_ports) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(module_type=module_types[0], front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(module_type=module_types[1], front_port=front_ports[1], rear_port=rear_ports[1]), ]) def test_q(self): @@ -2068,9 +2068,9 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ) FrontPortTemplate.objects.bulk_create(front_ports) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping(device_type=device_types[0], front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(device_type=device_types[1], front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(device_type=device_types[2], front_port=front_ports[2], rear_port=rear_ports[2]), ]) def test_name(self): @@ -2747,8 +2747,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): ) FrontPort.objects.bulk_create(front_ports) PortMapping.objects.bulk_create([ - PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(device=devices[0], front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(device=devices[1], front_port=front_ports[1], rear_port=rear_ports[1]), ]) ModuleBay.objects.create(device=devices[0], name='Module Bay 1') ModuleBay.objects.create(device=devices[1], name='Module Bay 2') @@ -5143,12 +5143,12 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil ) FrontPort.objects.bulk_create(front_ports) PortMapping.objects.bulk_create([ - PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortMapping(front_port=front_ports[1], rear_port=rear_ports[1], rear_port_position=2), - PortMapping(front_port=front_ports[2], rear_port=rear_ports[2], rear_port_position=3), - PortMapping(front_port=front_ports[3], rear_port=rear_ports[3]), - PortMapping(front_port=front_ports[4], rear_port=rear_ports[4]), - PortMapping(front_port=front_ports[5], rear_port=rear_ports[5]), + PortMapping(device=devices[0], front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(device=devices[1], front_port=front_ports[1], rear_port=rear_ports[1], rear_port_position=2), + PortMapping(device=devices[2], front_port=front_ports[2], rear_port=rear_ports[2], rear_port_position=3), + PortMapping(device=devices[3], front_port=front_ports[3], rear_port=rear_ports[3]), + PortMapping(device=devices[3], front_port=front_ports[4], rear_port=rear_ports[4]), + PortMapping(device=devices[3], front_port=front_ports[5], rear_port=rear_ports[5]), ]) # Cables @@ -6412,7 +6412,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): power_outlet = PowerOutlet.objects.create(device=devices[0], name='Power Outlet 1') rear_port = RearPort.objects.create(device=devices[0], name='Rear Port 1') front_port = FrontPort.objects.create(device=devices[0], name='Front Port 1') - PortMapping.objects.create(front_port=front_port, rear_port=rear_port) + PortMapping.objects.create(device=devices[0], front_port=front_port, rear_port=rear_port) power_panel = PowerPanel.objects.create(name='Power Panel 1', site=sites[0]) power_feed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 1f332ed21..b3eff0920 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -452,6 +452,7 @@ class DeviceTestCase(TestCase): frontport.save() PortTemplateMapping.objects.create( + device_type=device_type, front_port=frontport, rear_port=rearport, rear_port_position=2, @@ -848,10 +849,10 @@ class CableTestCase(TestCase): ) FrontPort.objects.bulk_create(front_ports) PortMapping.objects.bulk_create([ - PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), - PortMapping(front_port=front_ports[3], rear_port=rear_ports[3]), + PortMapping(device=patch_panel, front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(device=patch_panel, front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(device=patch_panel, front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping(device=patch_panel, front_port=front_ports[3], rear_port=rear_ports[3]), ]) provider = Provider.objects.create(name='Provider 1', slug='provider-1') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6b179cea8..1bf8ed99c 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -747,9 +747,9 @@ class DeviceTypeTestCase( ) FrontPortTemplate.objects.bulk_create(front_ports) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping(device_type=devicetype, front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(device_type=devicetype, front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(device_type=devicetype, front_port=front_ports[2], rear_port=rear_ports[2]), ]) url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk}) @@ -1319,9 +1319,9 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) FrontPortTemplate.objects.bulk_create(front_ports) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping(module_type=moduletype, front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(module_type=moduletype, front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(module_type=moduletype, front_port=front_ports[2], rear_port=rear_ports[2]), ]) url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk}) @@ -1777,9 +1777,9 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas ) FrontPortTemplate.objects.bulk_create(front_ports) PortTemplateMapping.objects.bulk_create([ - PortTemplateMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortTemplateMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortTemplateMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortTemplateMapping(device_type=devicetype, front_port=front_ports[0], rear_port=rear_ports[0]), + PortTemplateMapping(device_type=devicetype, front_port=front_ports[1], rear_port=rear_ports[1]), + PortTemplateMapping(device_type=devicetype, front_port=front_ports[2], rear_port=rear_ports[2]), ]) cls.form_data = { @@ -2271,9 +2271,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) FrontPort.objects.bulk_create(front_ports) PortMapping.objects.bulk_create([ - PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping(device=device, front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(device=device, front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(device=device, front_port=front_ports[2], rear_port=rear_ports[2]), ]) url = reverse('dcim:device_frontports', kwargs={'pk': device.pk}) @@ -3076,9 +3076,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): ) FrontPort.objects.bulk_create(front_ports) PortMapping.objects.bulk_create([ - PortMapping(front_port=front_ports[0], rear_port=rear_ports[0]), - PortMapping(front_port=front_ports[1], rear_port=rear_ports[1]), - PortMapping(front_port=front_ports[2], rear_port=rear_ports[2]), + PortMapping(device=device, front_port=front_ports[0], rear_port=rear_ports[0]), + PortMapping(device=device, front_port=front_ports[1], rear_port=rear_ports[1]), + PortMapping(device=device, front_port=front_ports[2], rear_port=rear_ports[2]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index f5b94829e..ce4a8c8d5 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -89,11 +89,9 @@ def create_port_mappings(device, device_type, module=None): """ Replicate all front/rear port mappings from a DeviceType to the given device. """ - from dcim.models import FrontPort, PortMapping, PortTemplateMapping, RearPort + from dcim.models import FrontPort, PortMapping, RearPort - templates = PortTemplateMapping.objects.filter( - front_port__device_type=device_type - ).prefetch_related('front_port', 'rear_port') + templates = device_type.port_mappings.prefetch_related('front_port', 'rear_port') # Cache front & rear ports for efficient lookups by name front_ports = { @@ -110,6 +108,7 @@ def create_port_mappings(device, device_type, module=None): rear_port = rear_ports.get(template.rear_port.resolve_name(module=module)) mappings.append( PortMapping( + device_id=front_port.device_id, front_port=front_port, front_port_position=template.front_port_position, rear_port=rear_port, From 715974cefcd8c9e6fa7ae739ea6f9363b2f71696 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 14:34:15 -0500 Subject: [PATCH 41/51] Update GraphQL types & filters --- netbox/dcim/graphql/filters.py | 37 +++++++++++++++++++++++++++++----- netbox/dcim/graphql/types.py | 8 ++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 98a4b67e4..11aff8201 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -6,7 +6,7 @@ import strawberry_django from strawberry.scalars import ID from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup -from core.graphql.filter_mixins import ChangeLogFilterMixin +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin from dcim import models from dcim.constants import * from dcim.graphql.enums import InterfaceKindEnum @@ -75,6 +75,8 @@ __all__ = ( 'ModuleTypeFilter', 'ModuleTypeProfileFilter', 'PlatformFilter', + 'PortMappingFilter', + 'PortTemplateMappingFilter', 'PowerFeedFilter', 'PowerOutletFilter', 'PowerOutletTemplateFilter', @@ -409,9 +411,6 @@ class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( - strawberry_django.filter_field() - ) @strawberry_django.filter_type(models.FrontPortTemplate, lookups=True) @@ -422,9 +421,37 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin): color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( strawberry_django.filter_field() ) - rear_ports: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + + +@strawberry_django.filter_type(models.PortMapping, lookups=True) +class PortMappingFilter(BaseObjectTypeFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + front_port: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) + rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + front_port_position: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field() + + +@strawberry_django.filter_type(models.PortTemplateMapping, lookups=True) +class PortTemplateMappingFilter(BaseObjectTypeFilterMixin): + device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + front_port: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + front_port_position: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.MACAddress, lookups=True) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 89f8d2f2b..1132a0ca9 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -641,7 +641,7 @@ class PlatformType(NestedGroupObjectType): @strawberry_django.type( models.PortMapping, fields='__all__', - # filters=PortMappingFilter, + filters=PortMappingFilter, pagination=True ) class PortMappingType(ModularComponentTemplateType): @@ -652,12 +652,12 @@ class PortMappingType(ModularComponentTemplateType): @strawberry_django.type( models.PortTemplateMapping, fields='__all__', - # filters=PortMappingTemplateFilter, + filters=PortTemplateMappingFilter, pagination=True ) class PortMappingTemplateType(ModularComponentTemplateType): - front_port_template: Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')] - rear_port_template: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] + front_port: Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')] + rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] @strawberry_django.type( From 9e367b8fc1d1d2748a2f5384ed09f8df4f96606d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 14:44:38 -0500 Subject: [PATCH 42/51] Update logic for handling split cable paths --- netbox/dcim/models/cables.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 1c1d67538..60799a813 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -954,16 +954,23 @@ class CablePath(models.Model): # RearPort splitting to multiple FrontPorts with no stack position if type(nodes[0]) is RearPort: - return FrontPort.objects.filter(rear_port__in=nodes) + return [ + mapping.front_port for mapping in + PortMapping.objects.filter(rear_port__in=nodes).prefetch_related('front_port') + ] # Cable terminating to multiple FrontPorts mapped to different # RearPorts connected to different cables - elif type(nodes[0]) is FrontPort: - return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes]) + if type(nodes[0]) is FrontPort: + return [ + mapping.rear_port for mapping in + PortMapping.objects.filter(front_port__in=nodes).prefetch_related('rear_port') + ] # Cable terminating to multiple CircuitTerminations - elif type(nodes[0]) is CircuitTermination: + if type(nodes[0]) is CircuitTermination: return [ ct.get_peer_termination() for ct in nodes ] + return [] def get_asymmetric_nodes(self): """ From fa70430942856e1f9ed0a0534bbbbeec2e8f4754 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Nov 2025 15:04:43 -0500 Subject: [PATCH 43/51] Enable defining port mappings when importing device/module types --- netbox/dcim/forms/object_import.py | 20 ++++++++++++++++++++ netbox/dcim/tests/test_views.py | 27 +++++++++++++++++++++++++-- netbox/dcim/views.py | 2 ++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index a0de2ad24..3b6a6e648 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -13,6 +13,7 @@ __all__ = ( 'InterfaceTemplateImportForm', 'InventoryItemTemplateImportForm', 'ModuleBayTemplateImportForm', + 'PortTemplateMappingImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', @@ -134,6 +135,25 @@ class RearPortTemplateImportForm(forms.ModelForm): ] +class PortTemplateMappingImportForm(forms.ModelForm): + front_port = forms.ModelChoiceField( + label=_('Front port'), + queryset=FrontPortTemplate.objects.all(), + to_field_name='name', + ) + rear_port = forms.ModelChoiceField( + label=_('Rear port'), + queryset=RearPortTemplate.objects.all(), + to_field_name='name', + ) + + class Meta: + model = PortTemplateMapping + fields = [ + 'front_port', 'front_port_position', 'rear_port', 'rear_port_position', + ] + + class ModuleBayTemplateImportForm(forms.ModelForm): class Meta: diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 1bf8ed99c..52b02d982 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -865,12 +865,16 @@ rear-ports: front-ports: - name: Front Port 1 type: 8p8c - rear_port: Rear Port 1 - name: Front Port 2 type: 8p8c - rear_port: Rear Port 2 - name: Front Port 3 type: 8p8c +port-mappings: + - front_port: Front Port 1 + rear_port: Rear Port 1 + - front_port: Front Port 2 + rear_port: Rear Port 2 + - front_port: Front Port 3 rear_port: Rear Port 3 module-bays: - name: Module Bay 1 @@ -971,6 +975,12 @@ inventory-items: fp1 = FrontPortTemplate.objects.first() self.assertEqual(fp1.name, 'Front Port 1') + self.assertEqual(device_type.port_mappings.count(), 3) + mapping1 = PortTemplateMapping.objects.first() + self.assertEqual(mapping1.device_type, device_type) + self.assertEqual(mapping1.front_port, fp1) + self.assertEqual(mapping1.rear_port, rp1) + self.assertEqual(device_type.modulebaytemplates.count(), 3) mb1 = ModuleBayTemplate.objects.first() self.assertEqual(mb1.name, 'Module Bay 1') @@ -1394,6 +1404,13 @@ front-ports: type: 8p8c - name: Front Port 3 type: 8p8c +port-mappings: + - front_port: Front Port 1 + rear_port: Rear Port 1 + - front_port: Front Port 2 + rear_port: Rear Port 2 + - front_port: Front Port 3 + rear_port: Rear Port 3 module-bays: - name: Module Bay 1 position: 1 @@ -1471,6 +1488,12 @@ module-bays: fp1 = FrontPortTemplate.objects.first() self.assertEqual(fp1.name, 'Front Port 1') + self.assertEqual(module_type.port_mappings.count(), 3) + mapping1 = PortTemplateMapping.objects.first() + self.assertEqual(mapping1.module_type, module_type) + self.assertEqual(mapping1.front_port, fp1) + self.assertEqual(mapping1.rear_port, rp1) + self.assertEqual(module_type.modulebaytemplates.count(), 3) mb1 = ModuleBayTemplate.objects.first() self.assertEqual(mb1.name, 'Module Bay 1') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ca2f11537..b78685f5b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1516,6 +1516,7 @@ class DeviceTypeImportView(generic.BulkImportView): 'interfaces': forms.InterfaceTemplateImportForm, 'rear-ports': forms.RearPortTemplateImportForm, 'front-ports': forms.FrontPortTemplateImportForm, + 'port-mappings': forms.PortTemplateMappingImportForm, 'module-bays': forms.ModuleBayTemplateImportForm, 'device-bays': forms.DeviceBayTemplateImportForm, 'inventory-items': forms.InventoryItemTemplateImportForm, @@ -1820,6 +1821,7 @@ class ModuleTypeImportView(generic.BulkImportView): 'interfaces': forms.InterfaceTemplateImportForm, 'rear-ports': forms.RearPortTemplateImportForm, 'front-ports': forms.FrontPortTemplateImportForm, + 'port-mappings': forms.PortTemplateMappingImportForm, 'module-bays': forms.ModuleBayTemplateImportForm, } From 665f91f6b31aa1531bb3d7c00de61c1d74e5b71e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Dec 2025 09:33:41 -0500 Subject: [PATCH 44/51] Extend REST API tests to check for updated port mappings after modifying front/rear ports --- netbox/dcim/tests/test_api.py | 104 ++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 419bac20f..d4783bc3c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1040,6 +1040,32 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): }, ] + cls.update_data = { + 'type': PortTypeChoices.TYPE_LC, + 'rear_ports': [ + { + 'position': 1, + 'rear_port': rear_port_templates[3].pk, + 'rear_port_position': 1, + }, + ], + } + + def test_update_object(self): + super().test_update_object() + + # Check that the port mapping was updated after modifying the front port template + front_port_template = FrontPortTemplate.objects.get(name='Front Port Template 1') + rear_port_template = RearPortTemplate.objects.get(name='Rear Port Template 4') + self.assertTrue( + PortTemplateMapping.objects.filter( + front_port=front_port_template, + front_port_position=1, + rear_port=rear_port_template, + rear_port_position=1, + ).exists() + ) + class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): model = RearPortTemplate @@ -1130,6 +1156,32 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): }, ] + cls.update_data = { + 'type': PortTypeChoices.TYPE_LC, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_port_templates[3].pk, + 'front_port_position': 1, + }, + ], + } + + def test_update_object(self): + super().test_update_object() + + # Check that the port mapping was updated after modifying the rear port template + front_port_template = FrontPortTemplate.objects.get(name='Front Port Template 4') + rear_port_template = RearPortTemplate.objects.get(name='Rear Port Template 1') + self.assertTrue( + PortTemplateMapping.objects.filter( + front_port=front_port_template, + front_port_position=1, + rear_port=rear_port_template, + rear_port_position=1, + ).exists() + ) + class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase): model = ModuleBayTemplate @@ -2109,6 +2161,32 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): }, ] + cls.update_data = { + 'type': PortTypeChoices.TYPE_LC, + 'rear_ports': [ + { + 'position': 1, + 'rear_port': rear_ports[3].pk, + 'rear_port_position': 1, + }, + ], + } + + def test_update_object(self): + super().test_update_object() + + # Check that the port mapping was updated after modifying the front port + front_port = FrontPort.objects.get(name='Front Port 1') + rear_port = RearPort.objects.get(name='Rear Port 4') + self.assertTrue( + PortMapping.objects.filter( + front_port=front_port, + front_port_position=1, + rear_port=rear_port, + rear_port_position=1, + ).exists() + ) + @tag('regression') # Issue #18991 def test_front_port_paths(self): device = Device.objects.first() @@ -2197,6 +2275,32 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): }, ] + cls.update_data = { + 'type': PortTypeChoices.TYPE_LC, + 'front_ports': [ + { + 'position': 1, + 'front_port': front_ports[3].pk, + 'front_port_position': 1, + }, + ], + } + + def test_update_object(self): + super().test_update_object() + + # Check that the port mapping was updated after modifying the rear port + front_port = FrontPort.objects.get(name='Front Port 4') + rear_port = RearPort.objects.get(name='Rear Port 1') + self.assertTrue( + PortMapping.objects.filter( + front_port=front_port, + front_port_position=1, + rear_port=rear_port, + rear_port_position=1, + ).exists() + ) + @tag('regression') # Issue #18991 def test_rear_port_paths(self): device = Device.objects.first() From 5597664b74de2c20afaa9f6f52a6b4fc82e0761e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Dec 2025 09:53:09 -0500 Subject: [PATCH 45/51] Cleaned up debugging logs in CablePath --- netbox/dcim/models/cables.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 60799a813..9d8c2c290 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,4 +1,5 @@ import itertools +import logging from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -30,6 +31,8 @@ __all__ = ( 'CableTermination', ) +logger = logging.getLogger(__name__) + trace_paths = Signal() @@ -666,14 +669,13 @@ class CablePath(models.Model): is_active = True is_split = False - DEBUG = False + logger.debug(f'Tracing cable path from {terminations}...') segment = 0 while terminations: segment += 1 - if DEBUG: - print(f'[#{segment}] Position stack: {position_stack}') - print(f'[#{segment}] Local terminations: {terminations}') + logger.debug(f'[Path segment #{segment}] Position stack: {position_stack}') + logger.debug(f'[Path segment #{segment}] Local terminations: {terminations}') # Terminations must all be of the same type if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]): @@ -707,8 +709,7 @@ class CablePath(models.Model): links = list(dict.fromkeys( termination.link for termination in terminations if termination.link is not None )) - if DEBUG: - print(f'[#{segment}] Links: {links}') + logger.debug(f'[Path segment #{segment}] Links: {links}') if len(links) == 0: if len(path) == 1: # If this is the start of the path and no link exists, return None @@ -771,12 +772,13 @@ class CablePath(models.Model): link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links ] - if DEBUG: - print(f'[#{segment}] Remote terminations: {remote_terminations}') + logger.debug(f'[Path segment #{segment}] Remote terminations: {remote_terminations}') + # Remote Terminations must all be of the same type, otherwise return a split path if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): is_complete = False is_split = True + logger.debug('Remote termination types differ; aborting trace.') break # Step 7: Record the far-end termination object(s) @@ -798,6 +800,10 @@ class CablePath(models.Model): port_mappings = PortMapping.objects.filter(q_filter) elif remote_terminations[0].positions > 1: is_split = True + logger.debug( + 'Encountered front port mapped to multiple rear ports but position stack is empty; aborting ' + 'trace.' + ) break else: port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations) @@ -819,6 +825,10 @@ class CablePath(models.Model): port_mappings = PortMapping.objects.filter(q_filter) elif remote_terminations[0].positions > 1: is_split = True + logger.debug( + 'Encountered rear port mapped to multiple front ports but position stack is empty; aborting ' + 'trace.' + ) break else: port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations) @@ -876,6 +886,7 @@ class CablePath(models.Model): # Unsupported topology, mark as split and exit is_complete = False is_split = True + logger.warning('Encountered an unsupported topology; aborting trace.') break return cls( From 463f37ae0431ac4381b29aace03595e9cb4f9df2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Dec 2025 10:57:41 -0500 Subject: [PATCH 46/51] Refactor form validation --- netbox/dcim/forms/mixins.py | 15 ++++++++++++--- netbox/dcim/forms/object_create.py | 18 ------------------ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 98d2117b6..91a0501dd 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -144,11 +144,20 @@ class FrontPortFormMixin(forms.Form): def clean(self): super().clean() - # Count of selected rear port & position pairs much match the assigned number of positions - if len(self.cleaned_data['rear_ports']) != self.cleaned_data['positions']: + # Check that the total number of FrontPorts and positions matches the selected number of RearPort:position + # mappings. Note that `name` will be a list under FrontPortCreateForm, in which cases we multiply the number of + # FrontPorts being creation by the number of positions. + positions = self.cleaned_data['positions'] + frontport_count = len(self.cleaned_data['name']) if type(self.cleaned_data['name']) is list else 1 + rearport_count = len(self.cleaned_data['rear_ports']) + if frontport_count * positions != rearport_count: raise forms.ValidationError({ 'rear_ports': _( - "The number of rear port/position pairs selected must match the number of positions assigned." + "The total number of front port positions ({frontport_count}) must match the selected number of " + "rear port positions ({rearport_count})." + ).format( + frontport_count=frontport_count, + rearport_count=rearport_count ) }) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 69678e185..5f0495c76 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -249,24 +249,6 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): 'device', 'module', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner', 'tags', ] - def clean(self): - super(NetBoxModelForm, self).clean() - - # Check that the number of FrontPorts to be created matches the selected number of RearPorts - positions = self.cleaned_data['positions'] - frontport_count = len(self.cleaned_data['name']) - rearport_count = len(self.cleaned_data['rear_ports']) - if frontport_count * positions != rearport_count: - raise forms.ValidationError({ - 'rear_ports': _( - "The number of front ports to be created ({frontport_count}) must match the selected number of " - "rear port positions ({rearport_count})." - ).format( - frontport_count=frontport_count, - rearport_count=rearport_count - ) - }) - def get_iterative_data(self, iteration): positions = self.cleaned_data['positions'] offset = positions * iteration From a3909d5c48a8786425b648aff46c05a471acfd9a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Dec 2025 11:48:20 -0500 Subject: [PATCH 47/51] Allow for null cable_position --- netbox/dcim/models/device_components.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index b629813de..e2077e9fe 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -210,10 +210,6 @@ class CabledObjectModel(models.Model): 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.") From d5ce6d2166f49bd92c283a2dedc5a68c544f6793 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Dec 2025 12:24:46 -0500 Subject: [PATCH 48/51] Add tests for new positions filters --- netbox/dcim/tests/test_filtersets.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 296f6d433..bd44d4f5d 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2048,6 +2048,7 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, device_type=device_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, + positions=1, color=ColorChoices.COLOR_RED, description='foobar1' ), @@ -2055,6 +2056,7 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_110_PUNCH, + positions=2, color=ColorChoices.COLOR_GREEN, description='foobar2' ), @@ -2062,6 +2064,7 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, device_type=device_types[2], name='Front Port 3', type=PortTypeChoices.TYPE_BNC, + positions=3, color=ColorChoices.COLOR_BLUE, description='foobar3' ), @@ -2085,6 +2088,10 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_positions(self): + params = {'positions': [1, 2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class RearPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): queryset = RearPortTemplate.objects.all() @@ -5118,6 +5125,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, + positions=2, _site=devices[3].site, _location=devices[3].location, _rack=devices[3].rack, @@ -5127,6 +5135,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, + positions=3, _site=devices[3].site, _location=devices[3].location, _rack=devices[3].rack, @@ -5136,6 +5145,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, + positions=4, _site=devices[3].site, _location=devices[3].location, _rack=devices[3].rack, @@ -5172,6 +5182,10 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_positions(self): + params = {'positions': [2, 3]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From 9198a0490a64113b30f7fcb5170907f2bc519160 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Dec 2025 12:25:34 -0500 Subject: [PATCH 49/51] Remove obsolete form validation logic --- netbox/dcim/forms/object_create.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 5f0495c76..8e0818326 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -127,26 +127,6 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp 'device_type', 'module_type', 'type', 'color', 'positions', 'description', ) - def clean(self): - # TODO - # super(ComponentCreateForm, self).clean() - - # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate - # positions - positions = self.cleaned_data['positions'] - frontport_count = len(self.cleaned_data['name']) - rearport_count = len(self.cleaned_data['rear_ports']) - if frontport_count * positions != rearport_count: - raise forms.ValidationError({ - 'rear_ports': _( - "The number of front port templates to be created ({frontport_count}) must match the selected " - "number of rear port positions ({rearport_count})." - ).format( - frontport_count=frontport_count, - rearport_count=rearport_count - ) - }) - def get_iterative_data(self, iteration): positions = self.cleaned_data['positions'] offset = positions * iteration From 619728a0489b5418415451befe0fbad96852187e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Dec 2025 13:35:14 -0500 Subject: [PATCH 50/51] Ensure cable paths are retraced when port mappings are changes via form --- netbox/dcim/forms/mixins.py | 12 ++++++++++++ netbox/dcim/models/cables.py | 2 +- netbox/dcim/signals.py | 26 +++++++++++++------------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 91a0501dd..b2fc46bc3 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -1,6 +1,8 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import connection +from django.db.models.signals import post_save from django.utils.translation import gettext_lazy as _ from dcim.constants import LOCATION_SCOPE_TYPES @@ -191,3 +193,13 @@ class FrontPortFormMixin(forms.Form): }) ) self.port_mapping_model.objects.bulk_create(mappings) + # Send post_save signals + for mapping in mappings: + post_save.send( + sender=PortMapping, + instance=mapping, + created=True, + raw=False, + using=connection, + update_fields=None + ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 9d8c2c290..e75b4c110 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -31,7 +31,7 @@ __all__ = ( 'CableTermination', ) -logger = logging.getLogger(__name__) +logger = logging.getLogger(f'netbox.{__name__}') trace_paths = Signal() diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 81cb0ae6c..5ec1f68d7 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,5 +1,6 @@ import logging +from django.db.models import Q from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @@ -7,7 +8,7 @@ from dcim.choices import CableEndChoices, LinkStatusChoices from virtualization.models import VMInterface from .models import ( Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface, - InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location, + InventoryItem, ModuleBay, PathEndpoint, PortMapping, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location, VirtualChassis, ) from .models.cables import trace_paths @@ -135,6 +136,17 @@ def retrace_cable_paths(instance, **kwargs): cablepath.retrace() +@receiver((post_delete, post_save), sender=PortMapping) +def update_passthrough_port_paths(instance, **kwargs): + """ + When a PortMapping is created or deleted, retrace any CablePaths which traverse its front and/or rear ports. + """ + for cablepath in CablePath.objects.filter( + Q(_nodes__contains=instance.front_port) | Q(_nodes__contains=instance.rear_port) + ): + cablepath.retrace() + + @receiver(post_delete, sender=CableTermination) def nullify_connected_endpoints(instance, **kwargs): """ @@ -150,18 +162,6 @@ def nullify_connected_endpoints(instance, **kwargs): cablepath.retrace() -# TODO: Adapt signal handler to act on changes to port mappings -@receiver(post_save, sender=FrontPort) -def extend_rearport_cable_paths(instance, created, raw, **kwargs): - """ - When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort. - """ - if created and not raw: - for mapping in instance.mappings.prefetch_related('rear_port'): - for cablepath in CablePath.objects.filter(_nodes__contains=mapping.rear_port): - cablepath.retrace() - - @receiver(post_save, sender=Interface) @receiver(post_save, sender=VMInterface) def update_mac_address_interface(instance, created, raw, **kwargs): From 107c1f25c8c1a8a23758bba7d431a5affd09d6bb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 4 Dec 2025 11:31:51 -0500 Subject: [PATCH 51/51] Add tests for PortMapping changes --- netbox/dcim/tests/test_cablepaths2.py | 202 ++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/netbox/dcim/tests/test_cablepaths2.py b/netbox/dcim/tests/test_cablepaths2.py index 66ab58257..0f9a704c5 100644 --- a/netbox/dcim/tests/test_cablepaths2.py +++ b/netbox/dcim/tests/test_cablepaths2.py @@ -839,3 +839,205 @@ class CablePathTests(CablePathTestCase): is_active=True ) self.assertEqual(CablePath.objects.count(), 4) + + def test_304_add_port_mapping_between_connected_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[rearport1] + ) + cable2.save() + + # Check for incomplete paths + self.assertPathExists( + (interface1, cable1, frontport1), + is_complete=False, + is_active=True + ) + self.assertPathExists( + (interface2, cable2, rearport1), + is_complete=False, + is_active=True + ) + + # Create a PortMapping between frontport1 and rearport1 + PortMapping.objects.create( + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, + ) + + # Check that paths are now complete + self.assertPathExists( + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, + is_active=True + ) + + def test_305_delete_port_mapping_between_connected_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[rearport1] + ) + cable2.save() + portmapping1 = PortMapping.objects.create( + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, + ) + + # Check for complete paths + self.assertPathExists( + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, + is_active=True + ) + + # Delete the PortMapping between frontport1 and rearport1 + portmapping1.delete() + + # Check that paths are no longer complete + self.assertPathExists( + (interface1, cable1, frontport1), + is_complete=False, + is_active=True + ) + self.assertPathExists( + (interface2, cable2, rearport1), + is_complete=False, + is_active=True + ) + + def test_306_change_port_mapping_between_connected_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C3-- [IF3] + [IF2] --C2-- [FP2] [RP3] --C4-- [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') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2') + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport2] + ) + cable2.save() + cable3 = Cable( + a_terminations=[interface3], + b_terminations=[rearport1] + ) + cable3.save() + cable4 = Cable( + a_terminations=[interface4], + b_terminations=[rearport2] + ) + cable4.save() + portmapping1 = PortMapping.objects.create( + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport1, + rear_port_position=1, + ) + + # Verify expected initial paths + self.assertPathExists( + (interface1, cable1, frontport1, rearport1, cable3, interface3), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interface3, cable3, rearport1, frontport1, cable1, interface1), + is_complete=True, + is_active=True + ) + + # Delete and replace the PortMapping to connect interface1 to interface4 + portmapping1.delete() + portmapping2 = PortMapping.objects.create( + device=self.device, + front_port=frontport1, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, + ) + + # Verify expected new paths + self.assertPathExists( + (interface1, cable1, frontport1, rearport2, cable4, interface4), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interface4, cable4, rearport2, frontport1, cable1, interface1), + is_complete=True, + is_active=True + ) + + # Delete and replace the PortMapping to connect interface2 to interface4 + portmapping2.delete() + PortMapping.objects.create( + device=self.device, + front_port=frontport2, + front_port_position=1, + rear_port=rearport2, + rear_port_position=1, + ) + + # Verify expected new paths + self.assertPathExists( + (interface2, cable2, frontport2, rearport2, cable4, interface4), + is_complete=True, + is_active=True + ) + self.assertPathExists( + (interface4, cable4, rearport2, frontport2, cable2, interface2), + is_complete=True, + is_active=True + )