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}')