From 66bbfa7a8820c51ade6d930eea70105c5fa423be Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 21 Nov 2025 13:15:57 -0500 Subject: [PATCH] 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, + }, + ], }, ]