Просмотр исходного кода

Closes #20564: Many-to-many pass-through port mappings (#20851)

Jeremy Stretch 2 месяцев назад
Родитель
Сommit
17d8f78ae3
35 измененных файлов с 2501 добавлено и 930 удалено
  1. 52 0
      netbox/dcim/api/serializers_/base.py
  2. 43 18
      netbox/dcim/api/serializers_/device_components.py
  3. 45 8
      netbox/dcim/api/serializers_/devicetype_components.py
  4. 2 2
      netbox/dcim/constants.py
  5. 22 4
      netbox/dcim/filtersets.py
  6. 1 30
      netbox/dcim/forms/bulk_import.py
  7. 76 1
      netbox/dcim/forms/mixins.py
  8. 90 25
      netbox/dcim/forms/model_forms.py
  9. 19 122
      netbox/dcim/forms/object_create.py
  10. 21 21
      netbox/dcim/forms/object_import.py
  11. 31 11
      netbox/dcim/graphql/filters.py
  12. 28 4
      netbox/dcim/graphql/types.py
  13. 219 0
      netbox/dcim/migrations/0222_port_mappings.py
  14. 65 0
      netbox/dcim/migrations/0223_frontport_positions.py
  15. 61 0
      netbox/dcim/models/base.py
  16. 66 48
      netbox/dcim/models/cables.py
  17. 82 46
      netbox/dcim/models/device_component_templates.py
  18. 58 46
      netbox/dcim/models/device_components.py
  19. 5 5
      netbox/dcim/models/devices.py
  20. 13 12
      netbox/dcim/signals.py
  21. 22 18
      netbox/dcim/tables/devices.py
  22. 10 5
      netbox/dcim/tables/devicetypes.py
  23. 252 62
      netbox/dcim/tests/test_api.py
  24. 649 254
      netbox/dcim/tests/test_cablepaths.py
  25. 302 47
      netbox/dcim/tests/test_cablepaths2.py
  26. 59 59
      netbox/dcim/tests/test_filtersets.py
  27. 4 2
      netbox/dcim/tests/test_forms.py
  28. 22 9
      netbox/dcim/tests/test_models.py
  29. 82 59
      netbox/dcim/tests/test_views.py
  30. 33 0
      netbox/dcim/utils.py
  31. 13 0
      netbox/dcim/views.py
  32. 3 3
      netbox/netbox/views/generic/object_views.py
  33. 26 7
      netbox/templates/dcim/frontport.html
  34. 24 1
      netbox/templates/dcim/rearport.html
  35. 1 1
      netbox/utilities/relations.py

+ 52 - 0
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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 
 
 __all__ = (
 __all__ = (
     'ConnectedEndpointsSerializer',
     'ConnectedEndpointsSerializer',
+    'PortSerializer',
 )
 )
 
 
 
 
@@ -35,3 +37,53 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
     @extend_schema_field(serializers.BooleanField)
     @extend_schema_field(serializers.BooleanField)
     def get_connected_endpoints_reachable(self, obj):
     def get_connected_endpoints_reachable(self, obj):
         return obj._path and obj._path.is_complete and obj._path.is_active
         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

+ 43 - 18
netbox/dcim/api/serializers_/device_components.py

@@ -5,21 +5,21 @@ from rest_framework import serializers
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import (
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
-    RearPort, VirtualDeviceContext,
+    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping,
+    PowerOutlet, PowerPort, RearPort, VirtualDeviceContext,
 )
 )
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.gfk_fields import GFKSerializerField
 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 vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
 from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
 from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
 from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
 from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
 from wireless.choices import *
 from wireless.choices import *
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
-from .base import ConnectedEndpointsSerializer
+from .base import ConnectedEndpointsSerializer, PortSerializer
 from .cables import CabledObjectSerializer
 from .cables import CabledObjectSerializer
 from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
 from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
 from .manufacturers import ManufacturerSerializer
 from .manufacturers import ManufacturerSerializer
@@ -294,7 +294,20 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         return super().validate(data)
         return super().validate(data)
 
 
 
 
-class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+class RearPortMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='rear_port_position'
+    )
+    front_port = serializers.PrimaryKeyRelatedField(
+        queryset=FrontPort.objects.all(),
+    )
+
+    class Meta:
+        model = PortMapping
+        fields = ('position', 'front_port', 'front_port_position')
+
+
+class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
     device = DeviceSerializer(nested=True)
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
     module = ModuleSerializer(
         nested=True,
         nested=True,
@@ -303,28 +316,36 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
         allow_null=True
         allow_null=True
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
+    front_ports = RearPortMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
             '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')
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
-class FrontPortRearPortSerializer(WritableNestedSerializer):
-    """
-    NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
-    """
+class FrontPortMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='front_port_position'
+    )
+    rear_port = serializers.PrimaryKeyRelatedField(
+        queryset=RearPort.objects.all(),
+    )
 
 
     class Meta:
     class Meta:
-        model = RearPort
-        fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
+        model = PortMapping
+        fields = ('position', 'rear_port', 'rear_port_position')
 
 
 
 
-class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
     device = DeviceSerializer(nested=True)
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
     module = ModuleSerializer(
         nested=True,
         nested=True,
@@ -333,14 +354,18 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
         allow_null=True
         allow_null=True
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
-    rear_port = FrontPortRearPortSerializer()
+    rear_ports = FrontPortMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = [
         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',
+            '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')
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 

+ 45 - 8
netbox/dcim/api/serializers_/devicetype_components.py

@@ -5,12 +5,14 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import (
 from dcim.models import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
-    InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate,
+    RearPortTemplate,
 )
 )
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from wireless.choices import *
 from wireless.choices import *
+from .base import PortSerializer
 from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
 from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
 from .manufacturers import ManufacturerSerializer
 from .manufacturers import ManufacturerSerializer
 from .nested import NestedInterfaceTemplateSerializer
 from .nested import NestedInterfaceTemplateSerializer
@@ -205,7 +207,20 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
-class RearPortTemplateSerializer(ComponentTemplateSerializer):
+class RearPortTemplateMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='rear_port_position'
+    )
+    front_port = serializers.PrimaryKeyRelatedField(
+        queryset=FrontPortTemplate.objects.all(),
+    )
+
+    class Meta:
+        model = PortTemplateMapping
+        fields = ('position', 'front_port', 'front_port_position')
+
+
+class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
     device_type = DeviceTypeSerializer(
     device_type = DeviceTypeSerializer(
         required=False,
         required=False,
         nested=True,
         nested=True,
@@ -219,17 +234,35 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer):
         default=None
         default=None
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
+    front_ports = RearPortTemplateMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
         fields = [
         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')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
-class FrontPortTemplateSerializer(ComponentTemplateSerializer):
+class FrontPortTemplateMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='front_port_position'
+    )
+    rear_port = serializers.PrimaryKeyRelatedField(
+        queryset=RearPortTemplate.objects.all(),
+    )
+
+    class Meta:
+        model = PortTemplateMapping
+        fields = ('position', 'rear_port', 'rear_port_position')
+
+
+class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
     device_type = DeviceTypeSerializer(
     device_type = DeviceTypeSerializer(
         nested=True,
         nested=True,
         required=False,
         required=False,
@@ -243,13 +276,17 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer):
         default=None
         default=None
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
-    rear_port = RearPortTemplateSerializer(nested=True)
+    rear_ports = FrontPortTemplateMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         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')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 

+ 2 - 2
netbox/dcim/constants.py

@@ -32,8 +32,8 @@ CABLE_POSITION_MAX = 1024
 # RearPorts
 # RearPorts
 #
 #
 
 
-REARPORT_POSITIONS_MIN = 1
-REARPORT_POSITIONS_MAX = 1024
+PORT_POSITION_MIN = 1
+PORT_POSITION_MAX = 1024
 
 
 
 
 #
 #

+ 22 - 4
netbox/dcim/filtersets.py

@@ -904,12 +904,15 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         null_value=None
         null_value=None
     )
     )
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=RearPort.objects.all()
+        field_name='mappings__rear_port',
+        queryset=RearPort.objects.all(),
+        to_field_name='rear_port',
+        label=_('Rear port (ID)'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
-        fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
+        fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
 
 
 
 
 @register_filterset
 @register_filterset
@@ -918,6 +921,12 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None
     )
     )
+    front_port_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='mappings__front_port',
+        queryset=FrontPort.objects.all(),
+        to_field_name='front_port',
+        label=_('Front port (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
@@ -2148,13 +2157,16 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
         null_value=None
         null_value=None
     )
     )
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=RearPort.objects.all()
+        field_name='mappings__rear_port',
+        queryset=RearPort.objects.all(),
+        to_field_name='rear_port',
+        label=_('Rear port (ID)'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = (
         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',
             'cable_position',
         )
         )
 
 
@@ -2165,6 +2177,12 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None
     )
     )
+    front_port_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='mappings__front_port',
+        queryset=FrontPort.objects.all(),
+        to_field_name='front_port',
+        label=_('Front port (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort

+ 1 - 30
netbox/dcim/forms/bulk_import.py

@@ -1091,12 +1091,6 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         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(
     type = CSVChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=PortTypeChoices,
         choices=PortTypeChoices,
@@ -1106,32 +1100,9 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = (
         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):
-        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):
 class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(

+ 76 - 1
netbox/dcim/forms/mixins.py

@@ -1,10 +1,12 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 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 django.utils.translation import gettext_lazy as _
 
 
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.constants import LOCATION_SCOPE_TYPES
-from dcim.models import Site
+from dcim.models import PortMapping, PortTemplateMapping, Site
 from utilities.forms import get_field_value
 from utilities.forms import get_field_value
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField,
     ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField,
@@ -13,6 +15,7 @@ from utilities.templatetags.builtins.filters import bettertitle
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 
 
 __all__ = (
 __all__ = (
+    'FrontPortFormMixin',
     'ScopedBulkEditForm',
     'ScopedBulkEditForm',
     'ScopedForm',
     'ScopedForm',
     'ScopedImportForm',
     'ScopedImportForm',
@@ -128,3 +131,75 @@ class ScopedImportForm(forms.Form):
                     "Please select a {scope_type}."
                     "Please select a {scope_type}."
                 ).format(scope_type=scope_type.model_class()._meta.model_name)
                 ).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_mapping_model = PortMapping
+    parent_field = 'device'
+
+    def clean(self):
+        super().clean()
+
+        # 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 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
+                )
+            })
+
+    def _save_m2m(self):
+        super()._save_m2m()
+
+        # TODO: Can this be made more efficient?
+        # Delete existing rear port mappings
+        self.port_mapping_model.objects.filter(front_port_id=self.instance.pk).delete()
+
+        # 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(**{
+                    **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)
+        # Send post_save signals
+        for mapping in mappings:
+            post_save.send(
+                sender=PortMapping,
+                instance=mapping,
+                created=True,
+                raw=False,
+                using=connection,
+                update_fields=None
+            )

+ 90 - 25
netbox/dcim/forms/model_forms.py

@@ -6,6 +6,7 @@ from timezone_field import TimeZoneFormField
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from dcim.forms.mixins import FrontPortFormMixin
 from dcim.models import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
@@ -1112,34 +1113,66 @@ 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',
-        }
-    )
-
+class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module Type')),
                 FieldSet('module_type', name=_('Module Type')),
             ),
             ),
-            'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
+            'name', 'label', 'type', 'positions', 'rear_ports', 'description',
         ),
         ),
     )
     )
 
 
+    # Override FrontPortFormMixin attrs
+    port_mapping_model = PortTemplateMapping
+    parent_field = 'device_type'
+
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         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',
+        ]
+
+    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 mappings
+        if self.instance.pk:
+            self.initial['rear_ports'] = [
+                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):
+        """
+        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'{mapping.rear_port_id}:{mapping.rear_port_position}'
+            for mapping in device_type.port_mappings.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):
 class RearPortTemplateForm(ModularComponentTemplateForm):
     fieldsets = (
     fieldsets = (
@@ -1578,17 +1611,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         }
         }
 
 
 
 
-class FrontPortForm(ModularDeviceComponentForm):
-    rear_port = DynamicModelChoiceField(
-        queryset=RearPort.objects.all(),
-        query_params={
-            'device_id': '$device',
-        }
-    )
-
+class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
     fieldsets = (
     fieldsets = (
         FieldSet(
         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',
             'description', 'tags',
         ),
         ),
     )
     )
@@ -1596,10 +1622,49 @@ class FrontPortForm(ModularDeviceComponentForm):
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = [
         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',
         ]
         ]
 
 
+    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
+        self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance)
+
+        # Set initial rear port mappings
+        if self.instance.pk:
+            self.initial['rear_ports'] = [
+                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):
+        """
+        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'{mapping.rear_port_id}:{mapping.rear_port_position}'
+            for mapping in device.port_mappings.exclude(front_port=front_port.pk)
+        ]
+
+        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):
 class RearPortForm(ModularDeviceComponentForm):
     fieldsets = (
     fieldsets = (

+ 19 - 122
netbox/dcim/forms/object_create.py

@@ -109,85 +109,30 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp
 
 
 
 
 class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
 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 = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module 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
-
-    def clean(self):
-        super().clean()
-
-        # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
-        # positions
-        frontport_count = len(self.cleaned_data['name'])
-        rearport_count = len(self.cleaned_data['rear_port'])
-        if frontport_count != rearport_count:
-            raise forms.ValidationError({
-                'rear_port': _(
-                    "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
-                )
-            })
+    class Meta:
+        model = FrontPortTemplate
+        fields = (
+            'device_type', 'module_type', 'type', 'color', 'positions', 'description',
+        )
 
 
     def get_iterative_data(self, iteration):
     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 {
         return {
-            'rear_port': int(rear_port),
-            'rear_port_position': int(position),
+            'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions]
         }
         }
 
 
 
 
@@ -269,74 +214,26 @@ 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
     # Override fieldsets from FrontPortForm to omit rear_port_position
     fieldsets = (
     fieldsets = (
         FieldSet(
         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', 'rear_port', 'rear_port_position')
-
-    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()
-
-        # 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'])
-        if frontport_count != rearport_count:
-            raise forms.ValidationError({
-                'rear_port': _(
-                    "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):
     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 {
         return {
-            'rear_port': int(rear_port),
-            'rear_port_position': int(position),
+            'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions]
         }
         }
 
 
 
 

+ 21 - 21
netbox/dcim/forms/object_import.py

@@ -13,6 +13,7 @@ __all__ = (
     'InterfaceTemplateImportForm',
     'InterfaceTemplateImportForm',
     'InventoryItemTemplateImportForm',
     'InventoryItemTemplateImportForm',
     'ModuleBayTemplateImportForm',
     'ModuleBayTemplateImportForm',
+    'PortTemplateMappingImportForm',
     'PowerOutletTemplateImportForm',
     'PowerOutletTemplateImportForm',
     'PowerPortTemplateImportForm',
     'PowerPortTemplateImportForm',
     'RearPortTemplateImportForm',
     'RearPortTemplateImportForm',
@@ -113,31 +114,11 @@ class FrontPortTemplateImportForm(forms.ModelForm):
         label=_('Type'),
         label=_('Type'),
         choices=PortTypeChoices.CHOICES
         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:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         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',
         ]
         ]
 
 
 
 
@@ -154,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 ModuleBayTemplateImportForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:

+ 31 - 11
netbox/dcim/graphql/filters.py

@@ -16,7 +16,8 @@ from dcim.graphql.filter_mixins import (
 from extras.graphql.filter_mixins import ConfigContextFilterMixin
 from extras.graphql.filter_mixins import ConfigContextFilterMixin
 from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin, WeightFilterMixin
 from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin, WeightFilterMixin
 from netbox.graphql.filters import (
 from netbox.graphql.filters import (
-    ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter, NetBoxModelFilter,
+    BaseModelFilter, ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
+    NetBoxModelFilter,
 )
 )
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
@@ -70,6 +71,8 @@ __all__ = (
     'ModuleTypeFilter',
     'ModuleTypeFilter',
     'ModuleTypeProfileFilter',
     'ModuleTypeProfileFilter',
     'PlatformFilter',
     'PlatformFilter',
+    'PortMappingFilter',
+    'PortTemplateMappingFilter',
     'PowerFeedFilter',
     'PowerFeedFilter',
     'PowerOutletFilter',
     'PowerOutletFilter',
     'PowerOutletTemplateFilter',
     'PowerOutletTemplateFilter',
@@ -404,13 +407,6 @@ class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         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 = (
-        strawberry_django.filter_field()
-    )
 
 
 
 
 @strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
 @strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
@@ -421,13 +417,37 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+
+
+@strawberry_django.filter_type(models.PortMapping, lookups=True)
+class PortMappingFilter(BaseModelFilter):
+    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()
         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 = (
+    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(BaseModelFilter):
+    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()
         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)
 @strawberry_django.filter_type(models.MACAddress, lookups=True)

+ 28 - 4
netbox/dcim/graphql/types.py

@@ -385,7 +385,8 @@ class DeviceTypeType(PrimaryObjectType):
 )
 )
 class FrontPortType(ModularComponentType, CabledObjectMixin):
 class FrontPortType(ModularComponentType, CabledObjectMixin):
     color: str
     color: str
-    rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
+
+    mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -396,7 +397,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
 )
 )
 class FrontPortTemplateType(ModularComponentTemplateType):
 class FrontPortTemplateType(ModularComponentTemplateType):
     color: str
     color: str
-    rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
+
+    mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -636,6 +638,28 @@ class PlatformType(NestedGroupObjectType):
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
+@strawberry_django.type(
+    models.PortMapping,
+    fields='__all__',
+    filters=PortMappingFilter,
+    pagination=True
+)
+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.PortTemplateMapping,
+    fields='__all__',
+    filters=PortTemplateMappingFilter,
+    pagination=True
+)
+class PortMappingTemplateType(ModularComponentTemplateType):
+    front_port: Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]
+    rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
+
+
 @strawberry_django.type(
 @strawberry_django.type(
     models.PowerFeed,
     models.PowerFeed,
     exclude=['_path'],
     exclude=['_path'],
@@ -768,7 +792,7 @@ class RackRoleType(OrganizationalObjectType):
 class RearPortType(ModularComponentType, CabledObjectMixin):
 class RearPortType(ModularComponentType, CabledObjectMixin):
     color: str
     color: str
 
 
-    frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
+    mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -780,7 +804,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
 class RearPortTemplateType(ModularComponentTemplateType):
 class RearPortTemplateType(ModularComponentTemplateType):
     color: str
     color: str
 
 
-    frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(

+ 219 - 0
netbox/dcim/migrations/0222_port_mappings.py

@@ -0,0 +1,219 @@
+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_template_mappings(apps, schema_editor):
+    FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate')
+    PortTemplateMapping = apps.get_model('dcim', 'PortTemplateMapping')
+
+    front_ports = FrontPortTemplate.objects.iterator(chunk_size=1000)
+
+    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,
+                rear_port_position=front_port.rear_port_position,
+            )
+
+    # Bulk insert in streaming batches
+    for chunk in chunked(generate_copies(), 1000):
+        PortTemplateMapping.objects.bulk_create(chunk, batch_size=1000)
+
+
+def populate_port_mappings(apps, schema_editor):
+    FrontPort = apps.get_model('dcim', 'FrontPort')
+    PortMapping = apps.get_model('dcim', 'PortMapping')
+
+    front_ports = FrontPort.objects.iterator(chunk_size=1000)
+
+    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,
+                rear_port_position=front_port.rear_port_position,
+            )
+
+    # Bulk insert in streaming batches
+    for chunk in chunked(generate_copies(), 1000):
+        PortMapping.objects.bulk_create(chunk, batch_size=1000)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0221_cable_position'),
+    ]
+
+    operations = [
+        # Create PortTemplateMapping model (for DeviceTypes)
+        migrations.CreateModel(
+            name='PortTemplateMapping',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                (
+                    'front_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024)
+                        ]
+                    )
+                ),
+                (
+                    'rear_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024)
+                        ]
+                    )
+                ),
+                (
+                    '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(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.frontporttemplate',
+                        related_name='mappings'
+                    )
+                ),
+                (
+                    'rear_port',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.rearporttemplate',
+                        related_name='mappings'
+                    )
+                ),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name='porttemplatemapping',
+            constraint=models.UniqueConstraint(
+                fields=('front_port', 'front_port_position'),
+                name='dcim_porttemplatemapping_unique_front_port_position'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='porttemplatemapping',
+            constraint=models.UniqueConstraint(
+                fields=('rear_port', 'rear_port_position'),
+                name='dcim_porttemplatemapping_unique_rear_port_position'
+            ),
+        ),
+
+        # Create PortMapping model (for Devices)
+        migrations.CreateModel(
+            name='PortMapping',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                (
+                    'front_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024)
+                        ]
+                    ),
+                ),
+                (
+                    'rear_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024),
+                        ]
+                    ),
+                ),
+                (
+                    'device',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.device',
+                        related_name='port_mappings'
+                    )
+                ),
+                (
+                    'front_port',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.frontport',
+                        related_name='mappings'
+                    )
+                ),
+                (
+                    'rear_port',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.rearport',
+                        related_name='mappings'
+                    )
+                ),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name='portmapping',
+            constraint=models.UniqueConstraint(
+                fields=('front_port', 'front_port_position'),
+                name='dcim_portmapping_unique_front_port_position'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='portmapping',
+            constraint=models.UniqueConstraint(
+                fields=('rear_port', 'rear_port_position'),
+                name='dcim_portmapping_unique_rear_port_position'
+            ),
+        ),
+
+        # Data migration
+        migrations.RunPython(
+            code=populate_port_template_mappings,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=populate_port_mappings,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 65 - 0
netbox/dcim/migrations/0223_frontport_positions.py

@@ -0,0 +1,65 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0222_port_mappings'),
+    ]
+
+    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',
+        ),
+        migrations.RemoveField(
+            model_name='frontport',
+            name='rear_port',
+        ),
+        migrations.RemoveField(
+            model_name='frontport',
+            name='rear_port_position',
+        ),
+
+        # Add positions on FrontPort
+        migrations.AddField(
+            model_name='frontport',
+            name='positions',
+            field=models.PositiveSmallIntegerField(
+                default=1,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024)
+                ]
+            ),
+        ),
+    ]

+ 61 - 0
netbox/dcim/models/base.py

@@ -0,0 +1,61 @@
+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__ = (
+    'PortMappingBase',
+)
+
+
+class PortMappingBase(models.Model):
+    """
+    Base class for PortMapping and PortTemplateMapping
+    """
+    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),
+        ),
+    )
+
+    _netbox_private = True
+
+    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
+        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
+                )
+            })

+ 66 - 48
netbox/dcim/models/cables.py

@@ -1,4 +1,5 @@
 import itertools
 import itertools
+import logging
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -22,7 +23,7 @@ from utilities.fields import ColorField, GenericArrayForeignKey
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.serialization import deserialize_object, serialize_object
 from utilities.serialization import deserialize_object, serialize_object
 from wireless.models import WirelessLink
 from wireless.models import WirelessLink
-from .device_components import FrontPort, PathEndpoint, RearPort
+from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
 
 
 __all__ = (
 __all__ = (
     'Cable',
     'Cable',
@@ -30,6 +31,8 @@ __all__ = (
     'CableTermination',
     'CableTermination',
 )
 )
 
 
+logger = logging.getLogger(f'netbox.{__name__}')
+
 trace_paths = Signal()
 trace_paths = Signal()
 
 
 
 
@@ -666,7 +669,13 @@ class CablePath(models.Model):
         is_active = True
         is_active = True
         is_split = False
         is_split = False
 
 
+        logger.debug(f'Tracing cable path from {terminations}...')
+
+        segment = 0
         while terminations:
         while terminations:
+            segment += 1
+            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
             # Terminations must all be of the same type
             if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
             if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
@@ -697,7 +706,10 @@ class CablePath(models.Model):
                 position_stack.append([terminations[0].cable_position])
                 position_stack.append([terminations[0].cable_position])
 
 
             # Step 2: Determine the attached links (Cable or WirelessLink), if any
             # 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
+            ))
+            logger.debug(f'[Path segment #{segment}] Links: {links}')
             if len(links) == 0:
             if len(links) == 0:
                 if len(path) == 1:
                 if len(path) == 1:
                     # If this is the start of the path and no link exists, return None
                     # If this is the start of the path and no link exists, return None
@@ -760,10 +772,13 @@ class CablePath(models.Model):
                     link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
                     link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
                 ]
                 ]
 
 
+            logger.debug(f'[Path segment #{segment}] Remote terminations: {remote_terminations}')
+
             # Remote Terminations must all be of the same type, otherwise return a split path
             # 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:]):
             if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
                 is_complete = False
                 is_complete = False
                 is_split = True
                 is_split = True
+                logger.debug('Remote termination types differ; aborting trace.')
                 break
                 break
 
 
             # Step 7: Record the far-end termination object(s)
             # Step 7: Record the far-end termination object(s)
@@ -777,58 +792,53 @@ class CablePath(models.Model):
 
 
             if isinstance(remote_terminations[0], FrontPort):
             if isinstance(remote_terminations[0], FrontPort):
                 # Follow FrontPorts to their corresponding RearPorts
                 # 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])
+                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=positions)
+                    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)
+                if not port_mappings:
+                    break
 
 
-                terminations = rear_ports
+                # Compile the list of RearPorts without duplication or altering their ordering
+                terminations = list(dict.fromkeys(mapping.rear_port for mapping in port_mappings))
+                if any(t.positions > 1 for t in terminations):
+                    position_stack.append([mapping.rear_port_position for mapping in port_mappings])
 
 
             elif isinstance(remote_terminations[0], RearPort):
             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:
+                # Follow RearPorts to their corresponding FrontPorts
+                if remote_terminations[0].positions > 1 and position_stack:
                     positions = position_stack.pop()
                     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()
                     q_filter = Q()
                     for rt in remote_terminations:
                     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()
+                        q_filter |= Q(rear_port=rt, rear_port_position__in=positions)
+                    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.'
                     )
                     )
-                # 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
+                    break
                 else:
                 else:
-                    # No position indicated: path has split, so we stop at the RearPorts
-                    is_split = True
+                    port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations)
+                if not port_mappings:
                     break
                     break
 
 
-                terminations = front_ports
+                # Compile the list of FrontPorts without duplication or altering their ordering
+                terminations = list(dict.fromkeys(mapping.front_port for mapping in port_mappings))
+                if any(t.positions > 1 for t in terminations):
+                    position_stack.append([mapping.front_port_position for mapping in port_mappings])
 
 
             elif isinstance(remote_terminations[0], CircuitTermination):
             elif isinstance(remote_terminations[0], CircuitTermination):
                 # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
                 # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
@@ -876,6 +886,7 @@ class CablePath(models.Model):
                     # Unsupported topology, mark as split and exit
                     # Unsupported topology, mark as split and exit
                     is_complete = False
                     is_complete = False
                     is_split = True
                     is_split = True
+                    logger.warning('Encountered an unsupported topology; aborting trace.')
                 break
                 break
 
 
         return cls(
         return cls(
@@ -954,16 +965,23 @@ class CablePath(models.Model):
 
 
         # RearPort splitting to multiple FrontPorts with no stack position
         # RearPort splitting to multiple FrontPorts with no stack position
         if type(nodes[0]) is RearPort:
         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
         # Cable terminating to multiple FrontPorts mapped to different
         # RearPorts connected to different cables
         # 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
         # Cable terminating to multiple CircuitTerminations
-        elif type(nodes[0]) is CircuitTermination:
+        if type(nodes[0]) is CircuitTermination:
             return [
             return [
                 ct.get_peer_termination() for ct in nodes
                 ct.get_peer_termination() for ct in nodes
             ]
             ]
+        return []
 
 
     def get_asymmetric_nodes(self):
     def get_asymmetric_nodes(self):
         """
         """

+ 82 - 46
netbox/dcim/models/device_component_templates.py

@@ -7,6 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -28,6 +29,7 @@ __all__ = (
     'InterfaceTemplate',
     'InterfaceTemplate',
     'InventoryItemTemplate',
     'InventoryItemTemplate',
     'ModuleBayTemplate',
     'ModuleBayTemplate',
+    'PortTemplateMapping',
     'PowerOutletTemplate',
     'PowerOutletTemplate',
     'PowerPortTemplate',
     'PowerPortTemplate',
     'RearPortTemplate',
     'RearPortTemplate',
@@ -518,6 +520,53 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         }
         }
 
 
 
 
+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,
+        related_name='mappings',
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPortTemplate',
+        on_delete=models.CASCADE,
+        related_name='mappings',
+    )
+
+    def clean(self):
+        super().clean()
+
+        # 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
+                )
+            })
+
+    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):
 class FrontPortTemplate(ModularComponentTemplateModel):
     """
     """
     Template for a pass-through port on the front of a new Device.
     Template for a pass-through port on the front of a new Device.
@@ -531,18 +580,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
         verbose_name=_('color'),
         verbose_name=_('color'),
         blank=True
         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,
         default=1,
         validators=[
         validators=[
-            MinValueValidator(REARPORT_POSITIONS_MIN),
-            MaxValueValidator(REARPORT_POSITIONS_MAX)
-        ]
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX)
+        ],
     )
     )
 
 
     component_model = FrontPort
     component_model = FrontPort
@@ -557,10 +601,6 @@ class FrontPortTemplate(ModularComponentTemplateModel):
                 fields=('module_type', 'name'),
                 fields=('module_type', 'name'),
                 name='%(app_label)s_%(class)s_unique_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 = _('front port template')
         verbose_name_plural = _('front port templates')
         verbose_name_plural = _('front port templates')
@@ -568,40 +608,23 @@ class FrontPortTemplate(ModularComponentTemplateModel):
     def clean(self):
     def clean(self):
         super().clean()
         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
+        # 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):
     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(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.resolve_label(kwargs.get('module')),
             label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             color=self.color,
             color=self.color,
-            rear_port=rear_port,
-            rear_port_position=self.rear_port_position,
+            positions=self.positions,
             **kwargs
             **kwargs
         )
         )
     instantiate.do_not_call_in_templates = True
     instantiate.do_not_call_in_templates = True
@@ -611,8 +634,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             'name': self.name,
             'name': self.name,
             'type': self.type,
             'type': self.type,
             'color': self.color,
             'color': self.color,
-            'rear_port': self.rear_port.name,
-            'rear_port_position': self.rear_port_position,
+            'positions': self.positions,
             'label': self.label,
             'label': self.label,
             'description': self.description,
             'description': self.description,
         }
         }
@@ -635,9 +657,9 @@ class RearPortTemplate(ModularComponentTemplateModel):
         verbose_name=_('positions'),
         verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         validators=[
-            MinValueValidator(REARPORT_POSITIONS_MIN),
-            MaxValueValidator(REARPORT_POSITIONS_MAX)
-        ]
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX)
+        ],
     )
     )
 
 
     component_model = RearPort
     component_model = RearPort
@@ -646,6 +668,20 @@ class RearPortTemplate(ModularComponentTemplateModel):
         verbose_name = _('rear port template')
         verbose_name = _('rear port template')
         verbose_name_plural = _('rear port templates')
         verbose_name_plural = _('rear port templates')
 
 
+    def clean(self):
+        super().clean()
+
+        # 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:
+                raise ValidationError({
+                    "positions": _(
+                        "The number of positions cannot be less than the number of mapped front port templates "
+                        "({count})"
+                    ).format(count=mapping_count)
+                })
+
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),

+ 58 - 46
netbox/dcim/models/device_components.py

@@ -11,6 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import WWNField
 from dcim.fields import WWNField
+from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
@@ -35,6 +36,7 @@ __all__ = (
     'InventoryItemRole',
     'InventoryItemRole',
     'ModuleBay',
     'ModuleBay',
     'PathEndpoint',
     'PathEndpoint',
+    'PortMapping',
     'PowerOutlet',
     'PowerOutlet',
     'PowerPort',
     'PowerPort',
     'RearPort',
     'RearPort',
@@ -208,10 +210,6 @@ class CabledObjectModel(models.Model):
                 raise ValidationError({
                 raise ValidationError({
                     "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
                     "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:
         if self.cable_end and not self.cable:
             raise ValidationError({
             raise ValidationError({
                 "cable_end": _("Cable end must not be set without a cable.")
                 "cable_end": _("Cable end must not be set without a cable.")
@@ -1069,6 +1067,43 @@ class Interface(
 # Pass-through ports
 # Pass-through ports
 #
 #
 
 
+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,
+        related_name='mappings',
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPort',
+        on_delete=models.CASCADE,
+        related_name='mappings',
+    )
+
+    def clean(self):
+        super().clean()
+
+        # 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(
+                    rear_port=self.rear_port
+                )
+            })
+
+    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):
 class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
     """
     """
     A pass-through port on the front of a Device.
     A pass-through port on the front of a Device.
@@ -1082,22 +1117,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
         verbose_name=_('color'),
         verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
-    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,
         default=1,
         validators=[
         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')
     )
     )
 
 
-    clone_fields = ('device', 'type', 'color')
+    clone_fields = ('device', 'type', 'color', 'positions')
 
 
     class Meta(ModularComponentModel.Meta):
     class Meta(ModularComponentModel.Meta):
         constraints = (
         constraints = (
@@ -1105,10 +1134,6 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
                 fields=('device', 'name'),
                 fields=('device', 'name'),
                 name='%(app_label)s_%(class)s_unique_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 = _('front port')
         verbose_name_plural = _('front ports')
         verbose_name_plural = _('front ports')
@@ -1116,27 +1141,14 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
     def clean(self):
     def clean(self):
         super().clean()
         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:
+        # 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({
                 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
-                    )
+                    "positions": _(
+                        "The number of positions cannot be less than the number of mapped rear ports ({count})"
+                    ).format(count=mapping_count)
                 })
                 })
 
 
 
 
@@ -1157,11 +1169,11 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
         verbose_name=_('positions'),
         verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         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')
     )
     )
+
     clone_fields = ('device', 'type', 'color', 'positions')
     clone_fields = ('device', 'type', 'color', 'positions')
 
 
     class Meta(ModularComponentModel.Meta):
     class Meta(ModularComponentModel.Meta):
@@ -1173,13 +1185,13 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 
 
         # Check that positions count is greater than or equal to the number of associated FrontPorts
         # Check that positions count is greater than or equal to the number of associated FrontPorts
         if not self._state.adding:
         if not self._state.adding:
-            frontport_count = self.frontports.count()
-            if self.positions < frontport_count:
+            mapping_count = self.mappings.count()
+            if self.positions < mapping_count:
                 raise ValidationError({
                 raise ValidationError({
                     "positions": _(
                     "positions": _(
                         "The number of positions cannot be less than the number of mapped front ports "
                         "The number of positions cannot be less than the number of mapped front ports "
-                        "({frontport_count})"
-                    ).format(frontport_count=frontport_count)
+                        "({count})"
+                    ).format(count=mapping_count)
                 })
                 })
 
 
 
 

+ 5 - 5
netbox/dcim/models/devices.py

@@ -1,8 +1,7 @@
 import decimal
 import decimal
-import yaml
-
 from functools import cached_property
 from functools import cached_property
 
 
+import yaml
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -19,14 +18,14 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
-from dcim.utils import update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.config import ConfigItem
 from netbox.config import ConfigItem
 from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
-from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+from netbox.models.mixins import WeightMixin
 from utilities.fields import ColorField, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
 from utilities.prefetch import get_prefetchable_fields
 from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
@@ -34,7 +33,6 @@ from .device_components import *
 from .mixins import RenderConfigMixin
 from .mixins import RenderConfigMixin
 from .modules import Module
 from .modules import Module
 
 
-
 __all__ = (
 __all__ = (
     'Device',
     'Device',
     'DeviceRole',
     'DeviceRole',
@@ -1009,6 +1007,8 @@ class Device(
             self._instantiate_components(self.device_type.interfacetemplates.all())
             self._instantiate_components(self.device_type.interfacetemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.frontporttemplates.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
             # Disable bulk_create to accommodate MPTT
             self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False)
             self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False)
             self._instantiate_components(self.device_type.devicebaytemplates.all())
             self._instantiate_components(self.device_type.devicebaytemplates.all())

+ 13 - 12
netbox/dcim/signals.py

@@ -1,5 +1,6 @@
 import logging
 import logging
 
 
+from django.db.models import Q
 from django.db.models.signals import post_save, post_delete
 from django.db.models.signals import post_save, post_delete
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
@@ -7,7 +8,7 @@ from dcim.choices import CableEndChoices, LinkStatusChoices
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 from .models import (
 from .models import (
     Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
     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,
     VirtualChassis,
 )
 )
 from .models.cables import trace_paths
 from .models.cables import trace_paths
@@ -135,6 +136,17 @@ def retrace_cable_paths(instance, **kwargs):
         cablepath.retrace()
         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)
 @receiver(post_delete, sender=CableTermination)
 def nullify_connected_endpoints(instance, **kwargs):
 def nullify_connected_endpoints(instance, **kwargs):
     """
     """
@@ -150,17 +162,6 @@ def nullify_connected_endpoints(instance, **kwargs):
         cablepath.retrace()
         cablepath.retrace()
 
 
 
 
-@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:
-        rearport = instance.rear_port
-        for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
-            cablepath.retrace()
-
-
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=VMInterface)
 @receiver(post_save, sender=VMInterface)
 def update_mac_address_interface(instance, created, raw, **kwargs):
 def update_mac_address_interface(instance, created, raw, **kwargs):

+ 22 - 18
netbox/dcim/tables/devices.py

@@ -749,12 +749,9 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
-    rear_port_position = tables.Column(
-        verbose_name=_('Position')
-    )
-    rear_port = tables.Column(
-        verbose_name=_('Rear Port'),
-        linkify=True
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}'
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:frontport_list'
         url_name='dcim:frontport_list'
@@ -763,12 +760,12 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.FrontPort
         model = models.FrontPort
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
-            'rear_port_position', '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', 'mappings',
+            'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created',
+            'last_updated',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
+            'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'mappings', 'description',
         )
         )
 
 
 
 
@@ -786,11 +783,11 @@ class DeviceFrontPortTable(FrontPortTable):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.FrontPort
         model = models.FrontPort
         fields = (
         fields = (
-            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
+            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings',
             'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
             'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
+            'pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'cable', 'link_peer',
         )
         )
 
 
 
 
@@ -805,6 +802,10 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}'
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:rearport_list'
         url_name='dcim:rearport_list'
     )
     )
@@ -812,10 +813,13 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.RearPort
         model = models.RearPort
         fields = (
         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', 'mappings',
+            'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created',
+            'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'mappings', 'description',
         )
         )
-        default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
 
 
 
 
 class DeviceRearPortTable(RearPortTable):
 class DeviceRearPortTable(RearPortTable):
@@ -832,11 +836,11 @@ class DeviceRearPortTable(RearPortTable):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.RearPort
         model = models.RearPort
         fields = (
         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', 'mappings',
+            'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
+            'pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'cable', 'link_peer',
         )
         )
 
 
 
 

+ 10 - 5
netbox/dcim/tables/devicetypes.py

@@ -249,12 +249,13 @@ class InterfaceTemplateTable(ComponentTemplateTable):
 
 
 
 
 class FrontPortTemplateTable(ComponentTemplateTable):
 class FrontPortTemplateTable(ComponentTemplateTable):
-    rear_port_position = tables.Column(
-        verbose_name=_('Position')
-    )
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}'
+    )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
@@ -262,7 +263,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
 
 
     class Meta(ComponentTemplateTable.Meta):
     class Meta(ComponentTemplateTable.Meta):
         model = models.FrontPortTemplate
         model = models.FrontPortTemplate
-        fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
+        fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
 
 
 
 
@@ -270,6 +271,10 @@ class RearPortTemplateTable(ComponentTemplateTable):
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}'
+    )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
@@ -277,7 +282,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
 
 
     class Meta(ComponentTemplateTable.Meta):
     class Meta(ComponentTemplateTable.Meta):
         model = models.RearPortTemplate
         model = models.RearPortTemplate
-        fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
+        fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
 
 
 
 

+ 252 - 62
netbox/dcim/tests/test_api.py

@@ -973,72 +973,99 @@ 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 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 3', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', 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)
         RearPortTemplate.objects.bulk_create(rear_port_templates)
-
         front_port_templates = (
         front_port_templates = (
-            FrontPortTemplate(
+            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.objects.bulk_create(front_port_templates)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(
                 device_type=devicetype,
                 device_type=devicetype,
-                name='Front Port Template 1',
-                type=PortTypeChoices.TYPE_8P8C,
-                rear_port=rear_port_templates[0]
+                front_port=front_port_templates[0],
+                rear_port=rear_port_templates[0],
             ),
             ),
-            FrontPortTemplate(
+            PortTemplateMapping(
                 device_type=devicetype,
                 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]
+                front_port=front_port_templates[1],
+                rear_port=rear_port_templates[1],
             ),
             ),
-            FrontPortTemplate(
+            PortTemplateMapping(
                 module_type=moduletype,
                 module_type=moduletype,
-                name='Front Port Template 6',
-                type=PortTypeChoices.TYPE_8P8C,
-                rear_port=rear_port_templates[5]
+                front_port=front_port_templates[2],
+                rear_port=rear_port_templates[2],
             ),
             ),
-        )
-        FrontPortTemplate.objects.bulk_create(front_port_templates)
+        ])
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Front Port Template 3',
                 'name': 'Front Port Template 3',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_port_templates[2].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_port_templates[3].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Front Port Template 4',
                 'name': 'Front Port Template 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_port_templates[3].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_port_templates[4].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'module_type': moduletype.pk,
                 'module_type': moduletype.pk,
                 'name': 'Front Port Template 7',
                 'name': 'Front Port Template 7',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                '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': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_port_templates[5].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        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):
 class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = RearPortTemplate
     model = RearPortTemplate
@@ -1057,36 +1084,104 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
             manufacturer=manufacturer, model='Module Type 1'
             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 = (
         rear_port_templates = (
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
             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 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 3', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPortTemplate.objects.bulk_create(rear_port_templates)
         RearPortTemplate.objects.bulk_create(rear_port_templates)
+        PortTemplateMapping.objects.bulk_create([
+            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 = [
         cls.create_data = [
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Rear Port Template 4',
                 'name': 'Rear Port Template 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_port_templates[3].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Rear Port Template 5',
                 'name': 'Rear Port Template 5',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_port_templates[4].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'module_type': moduletype.pk,
                 'module_type': moduletype.pk,
                 'name': 'Rear Port Template 6',
                 'name': 'Rear Port Template 6',
                 'type': PortTypeChoices.TYPE_8P8C,
                 '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,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        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):
 class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ModuleBayTemplate
     model = ModuleBayTemplate
@@ -2015,51 +2110,90 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
             RearPort(device=device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
-
         front_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)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            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 = [
         cls.create_data = [
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Front Port 4',
                 'name': 'Front Port 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_ports[3].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_ports[3].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Front Port 5',
                 'name': 'Front Port 5',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_ports[4].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_ports[4].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Front Port 6',
                 'name': 'Front Port 6',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_ports[5].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_ports[5].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        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
     @tag('regression')  # Issue #18991
     def test_front_port_paths(self):
     def test_front_port_paths(self):
         device = Device.objects.first()
         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')
         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)
+        PortMapping.objects.create(device=device, front_port=front_port, rear_port=rear_port)
         Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
         Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
 
 
         self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
         self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
@@ -2086,6 +2220,15 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
         role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
         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)
         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 = (
         rear_ports = (
             RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
@@ -2098,19 +2241,66 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Rear Port 4',
                 'name': 'Rear Port 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_ports[3].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Rear Port 5',
                 'name': 'Rear Port 5',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_ports[4].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Rear Port 6',
                 'name': 'Rear Port 6',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_ports[5].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        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
     @tag('regression')  # Issue #18991
     def test_rear_port_paths(self):
     def test_rear_port_paths(self):
         device = Device.objects.first()
         device = Device.objects.first()

+ 649 - 254
netbox/dcim/tests/test_cablepaths.py

@@ -281,9 +281,14 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         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')
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport1,
+            rear_port_position=1
         )
         )
 
 
         # Create cable 1
         # Create cable 1
@@ -340,9 +345,14 @@ class LegacyCablePathTests(CablePathTestCase):
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         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')
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport1,
+            rear_port_position=1
         )
         )
 
 
         # Create cable 1
         # Create cable 1
@@ -403,18 +413,40 @@ class LegacyCablePathTests(CablePathTestCase):
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         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=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -521,18 +553,40 @@ class LegacyCablePathTests(CablePathTestCase):
         interface8 = Interface.objects.create(device=self.device, name='Interface 8')
         interface8 = Interface.objects.create(device=self.device, name='Interface 8')
         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=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -680,27 +734,59 @@ class LegacyCablePathTests(CablePathTestCase):
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         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=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)
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_1,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_2,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2, 6-7
         # Create cables 1-2, 6-7
         cable1 = Cable(
         cable1 = Cable(
@@ -801,30 +887,72 @@ class LegacyCablePathTests(CablePathTestCase):
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         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)
         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)
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_1,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_2,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_1,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_2,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-3, 6-8
         # Create cables 1-3, 6-8
         cable1 = Cable(
         cable1 = Cable(
@@ -928,23 +1056,50 @@ class LegacyCablePathTests(CablePathTestCase):
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         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=4)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
-        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
-        )
+        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 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_1,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_2,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2, 5-6
         # Create cables 1-2, 5-6
         cable1 = Cable(
         cable1 = Cable(
@@ -1032,13 +1187,25 @@ class LegacyCablePathTests(CablePathTestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         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
-        )
+        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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1
         # Create cables 1
         cable1 = Cable(
         cable1 = Cable(
@@ -1098,10 +1265,11 @@ class LegacyCablePathTests(CablePathTestCase):
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         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')
+        PortMapping.objects.create(
+            front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1,
         )
         )
 
 
         # Create cables
         # Create cables
@@ -1413,18 +1581,40 @@ class LegacyCablePathTests(CablePathTestCase):
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         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=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
         circuittermination1 = CircuitTermination.objects.create(
         circuittermination1 = CircuitTermination.objects.create(
             circuit=self.circuit,
             circuit=self.circuit,
             termination=self.site,
             termination=self.site,
@@ -1601,22 +1791,44 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -1688,30 +1900,72 @@ class LegacyCablePathTests(CablePathTestCase):
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         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=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_3,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=3,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_4,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=4,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_3,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=3,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_4,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=4,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -1858,22 +2112,44 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],
@@ -1937,22 +2213,44 @@ class LegacyCablePathTests(CablePathTestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],
@@ -2033,30 +2331,62 @@ class LegacyCablePathTests(CablePathTestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport5,
+                front_port_position=1,
+                rear_port=rearport5,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport6,
+                front_port_position=1,
+                rear_port=rearport6,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],
@@ -2155,14 +2485,26 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable1 = Cable(
         cable1 = Cable(
             a_terminations=[interface1],
             a_terminations=[interface1],
@@ -2274,14 +2616,26 @@ class LegacyCablePathTests(CablePathTestCase):
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -2320,14 +2674,26 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cable 2
         # Create cable 2
         cable2 = Cable(
         cable2 = Cable(
@@ -2373,10 +2739,17 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables 1 and 2
         # Create cables 1 and 2
         cable1 = Cable(
         cable1 = Cable(
@@ -2478,22 +2851,44 @@ class LegacyCablePathTests(CablePathTestCase):
         )
         )
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],

+ 302 - 47
netbox/dcim/tests/test_cablepaths2.py

@@ -1,5 +1,3 @@
-from unittest import skipIf
-
 from circuits.models import CircuitTermination
 from circuits.models import CircuitTermination
 from dcim.choices import CableProfileChoices
 from dcim.choices import CableProfileChoices
 from dcim.models import *
 from dcim.models import *
@@ -363,13 +361,17 @@ class CablePathTests(CablePathTestCase):
             Interface.objects.create(device=self.device, name='Interface 3'),
             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 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -439,18 +441,40 @@ class CablePathTests(CablePathTestCase):
         ]
         ]
         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=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -654,25 +678,47 @@ class CablePathTests(CablePathTestCase):
             Interface.objects.create(device=self.device, name='Interface 2'),
             Interface.objects.create(device=self.device, name='Interface 2'),
         ]
         ]
         rear_ports = [
         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 = [
         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 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'),
+        ]
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[0],
+                front_port_position=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
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[1],
+                front_port_position=1,
+                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
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[2],
+                front_port_position=1,
+                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
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[3],
+                front_port_position=1,
+                rear_port=rear_ports[3],
+                rear_port_position=1,
             ),
             ),
-        ]
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -723,8 +769,6 @@ class CablePathTests(CablePathTestCase):
         # Test SVG generation
         # Test SVG generation
         CableTraceSVG(interfaces[0]).render()
         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):
     def test_223_single_path_via_multiple_pass_throughs_with_breakouts(self):
         """
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
@@ -736,14 +780,26 @@ class CablePathTests(CablePathTestCase):
             Interface.objects.create(device=self.device, name='Interface 3'),
             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 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')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -761,9 +817,6 @@ class CablePathTests(CablePathTestCase):
         cable2.clean()
         cable2.clean()
         cable2.save()
         cable2.save()
 
 
-        for path in CablePath.objects.all():
-            print(f'{path}: {path.path_objects}')
-
         # Validate paths
         # Validate paths
         self.assertPathExists(
         self.assertPathExists(
             (interfaces[0], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[2]),
             (interfaces[0], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[2]),
@@ -786,3 +839,205 @@ class CablePathTests(CablePathTestCase):
             is_active=True
             is_active=True
         )
         )
         self.assertEqual(CablePath.objects.count(), 4)
         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
+        )

+ 59 - 59
netbox/dcim/tests/test_filtersets.py

@@ -1362,22 +1362,15 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             RearPortTemplate(device_type=device_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=device_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         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)
+        PortTemplateMapping.objects.bulk_create([
+            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.objects.bulk_create((
             ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'),
             ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'),
             ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'),
             ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'),
@@ -1633,22 +1626,15 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             RearPortTemplate(module_type=module_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(module_type=module_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         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)
+        PortTemplateMapping.objects.bulk_create([
+            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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -2064,32 +2050,38 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
 
 
-        FrontPortTemplate.objects.bulk_create((
+        front_ports = (
             FrontPortTemplate(
             FrontPortTemplate(
                 device_type=device_types[0],
                 device_type=device_types[0],
                 name='Front Port 1',
                 name='Front Port 1',
-                rear_port=rear_ports[0],
                 type=PortTypeChoices.TYPE_8P8C,
                 type=PortTypeChoices.TYPE_8P8C,
+                positions=1,
                 color=ColorChoices.COLOR_RED,
                 color=ColorChoices.COLOR_RED,
                 description='foobar1'
                 description='foobar1'
             ),
             ),
             FrontPortTemplate(
             FrontPortTemplate(
                 device_type=device_types[1],
                 device_type=device_types[1],
                 name='Front Port 2',
                 name='Front Port 2',
-                rear_port=rear_ports[1],
                 type=PortTypeChoices.TYPE_110_PUNCH,
                 type=PortTypeChoices.TYPE_110_PUNCH,
+                positions=2,
                 color=ColorChoices.COLOR_GREEN,
                 color=ColorChoices.COLOR_GREEN,
                 description='foobar2'
                 description='foobar2'
             ),
             ),
             FrontPortTemplate(
             FrontPortTemplate(
                 device_type=device_types[2],
                 device_type=device_types[2],
                 name='Front Port 3',
                 name='Front Port 3',
-                rear_port=rear_ports[2],
                 type=PortTypeChoices.TYPE_BNC,
                 type=PortTypeChoices.TYPE_BNC,
+                positions=3,
                 color=ColorChoices.COLOR_BLUE,
                 color=ColorChoices.COLOR_BLUE,
                 description='foobar3'
                 description='foobar3'
             ),
             ),
-        ))
+        )
+        FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            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):
     def test_name(self):
         params = {'name': ['Front Port 1', 'Front Port 2']}
         params = {'name': ['Front Port 1', 'Front Port 2']}
@@ -2103,6 +2095,10 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class RearPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = RearPortTemplate.objects.all()
     queryset = RearPortTemplate.objects.all()
@@ -2759,10 +2755,15 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
             RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         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)
+        PortMapping.objects.bulk_create([
+            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[0], name='Module Bay 1')
         ModuleBay.objects.create(device=devices[1], name='Module Bay 2')
         ModuleBay.objects.create(device=devices[1], name='Module Bay 2')
         DeviceBay.objects.bulk_create((
         DeviceBay.objects.bulk_create((
@@ -5158,8 +5159,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 label='A',
                 label='A',
                 type=PortTypeChoices.TYPE_8P8C,
                 type=PortTypeChoices.TYPE_8P8C,
                 color=ColorChoices.COLOR_RED,
                 color=ColorChoices.COLOR_RED,
-                rear_port=rear_ports[0],
-                rear_port_position=1,
                 description='First',
                 description='First',
                 _site=devices[0].site,
                 _site=devices[0].site,
                 _location=devices[0].location,
                 _location=devices[0].location,
@@ -5172,8 +5171,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 label='B',
                 label='B',
                 type=PortTypeChoices.TYPE_110_PUNCH,
                 type=PortTypeChoices.TYPE_110_PUNCH,
                 color=ColorChoices.COLOR_GREEN,
                 color=ColorChoices.COLOR_GREEN,
-                rear_port=rear_ports[1],
-                rear_port_position=2,
                 description='Second',
                 description='Second',
                 _site=devices[1].site,
                 _site=devices[1].site,
                 _location=devices[1].location,
                 _location=devices[1].location,
@@ -5186,8 +5183,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 label='C',
                 label='C',
                 type=PortTypeChoices.TYPE_BNC,
                 type=PortTypeChoices.TYPE_BNC,
                 color=ColorChoices.COLOR_BLUE,
                 color=ColorChoices.COLOR_BLUE,
-                rear_port=rear_ports[2],
-                rear_port_position=3,
                 description='Third',
                 description='Third',
                 _site=devices[2].site,
                 _site=devices[2].site,
                 _location=devices[2].location,
                 _location=devices[2].location,
@@ -5198,8 +5193,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 name='Front Port 4',
                 name='Front Port 4',
                 label='D',
                 label='D',
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
-                rear_port=rear_ports[3],
-                rear_port_position=1,
+                positions=2,
                 _site=devices[3].site,
                 _site=devices[3].site,
                 _location=devices[3].location,
                 _location=devices[3].location,
                 _rack=devices[3].rack,
                 _rack=devices[3].rack,
@@ -5209,8 +5203,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 name='Front Port 5',
                 name='Front Port 5',
                 label='E',
                 label='E',
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
-                rear_port=rear_ports[4],
-                rear_port_position=1,
+                positions=3,
                 _site=devices[3].site,
                 _site=devices[3].site,
                 _location=devices[3].location,
                 _location=devices[3].location,
                 _rack=devices[3].rack,
                 _rack=devices[3].rack,
@@ -5220,14 +5213,21 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 name='Front Port 6',
                 name='Front Port 6',
                 label='F',
                 label='F',
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
-                rear_port=rear_ports[5],
-                rear_port_position=1,
+                positions=4,
                 _site=devices[3].site,
                 _site=devices[3].site,
                 _location=devices[3].location,
                 _location=devices[3].location,
                 _rack=devices[3].rack,
                 _rack=devices[3].rack,
             ),
             ),
         )
         )
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            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
         # Cables
         Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
         Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
@@ -5250,6 +5250,10 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_description(self):
         params = {'description': ['First', 'Second']}
         params = {'description': ['First', 'Second']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -6518,13 +6522,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
         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_port = PowerPort.objects.create(device=devices[0], name='Power Port 1')
         power_outlet = PowerOutlet.objects.create(device=devices[0], name='Power Outlet 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')
+        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_panel = PowerPanel.objects.create(name='Power Panel 1', site=sites[0])
         power_feed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel)
         power_feed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel)

+ 4 - 2
netbox/dcim/tests/test_forms.py

@@ -193,7 +193,8 @@ class FrontPortTestCase(TestCase):
             'name': 'FrontPort[1-4]',
             'name': 'FrontPort[1-4]',
             'label': 'Port[1-4]',
             'label': 'Port[1-4]',
             'type': PortTypeChoices.TYPE_8P8C,
             '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)
         form = FrontPortCreateForm(front_port_data)
 
 
@@ -208,7 +209,8 @@ class FrontPortTestCase(TestCase):
             'name': 'FrontPort[1-4]',
             'name': 'FrontPort[1-4]',
             'label': 'Port[1-2]',
             'label': 'Port[1-2]',
             'type': PortTypeChoices.TYPE_8P8C,
             '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)
         form = FrontPortCreateForm(bad_front_port_data)
 
 

+ 22 - 9
netbox/dcim/tests/test_models.py

@@ -444,13 +444,19 @@ class DeviceTestCase(TestCase):
         )
         )
         rearport.save()
         rearport.save()
 
 
-        FrontPortTemplate(
+        frontport = FrontPortTemplate(
             device_type=device_type,
             device_type=device_type,
             name='Front Port 1',
             name='Front Port 1',
             type=PortTypeChoices.TYPE_8P8C,
             type=PortTypeChoices.TYPE_8P8C,
+        )
+        frontport.save()
+
+        PortTemplateMapping.objects.create(
+            device_type=device_type,
+            front_port=frontport,
             rear_port=rearport,
             rear_port=rearport,
-            rear_port_position=2
-        ).save()
+            rear_port_position=2,
+        )
 
 
         ModuleBayTemplate(
         ModuleBayTemplate(
             device_type=device_type,
             device_type=device_type,
@@ -528,11 +534,12 @@ class DeviceTestCase(TestCase):
             device=device,
             device=device,
             name='Front Port 1',
             name='Front Port 1',
             type=PortTypeChoices.TYPE_8P8C,
             type=PortTypeChoices.TYPE_8P8C,
-            rear_port=rearport,
-            rear_port_position=2
+            positions=1
         )
         )
         self.assertEqual(frontport.cf['cf1'], 'foo')
         self.assertEqual(frontport.cf['cf1'], 'foo')
 
 
+        self.assertTrue(PortMapping.objects.filter(front_port=frontport, rear_port=rearport).exists())
+
         modulebay = ModuleBay.objects.get(
         modulebay = ModuleBay.objects.get(
             device=device,
             device=device,
             name='Module Bay 1'
             name='Module Bay 1'
@@ -881,12 +888,18 @@ class CableTestCase(TestCase):
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
         front_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)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            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')
         provider = Provider.objects.create(name='Provider 1', slug='provider-1')
         provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
         provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)

+ 82 - 59
netbox/dcim/tests/test_views.py

@@ -741,17 +741,16 @@ class DeviceTypeTestCase(
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
         front_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)
         FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            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})
         url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
@@ -866,12 +865,16 @@ rear-ports:
 front-ports:
 front-ports:
   - name: Front Port 1
   - name: Front Port 1
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 1
   - name: Front Port 2
   - name: Front Port 2
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 2
   - name: Front Port 3
   - name: Front Port 3
     type: 8p8c
     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
     rear_port: Rear Port 3
 module-bays:
 module-bays:
   - name: Module Bay 1
   - name: Module Bay 1
@@ -971,8 +974,12 @@ inventory-items:
         self.assertEqual(device_type.frontporttemplates.count(), 3)
         self.assertEqual(device_type.frontporttemplates.count(), 3)
         fp1 = FrontPortTemplate.objects.first()
         fp1 = FrontPortTemplate.objects.first()
         self.assertEqual(fp1.name, 'Front Port 1')
         self.assertEqual(fp1.name, 'Front Port 1')
-        self.assertEqual(fp1.rear_port, rp1)
-        self.assertEqual(fp1.rear_port_position, 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)
         self.assertEqual(device_type.modulebaytemplates.count(), 3)
         mb1 = ModuleBayTemplate.objects.first()
         mb1 = ModuleBayTemplate.objects.first()
@@ -1316,17 +1323,16 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
         front_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)
         FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            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})
         url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
@@ -1394,12 +1400,16 @@ rear-ports:
 front-ports:
 front-ports:
   - name: Front Port 1
   - name: Front Port 1
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 1
   - name: Front Port 2
   - name: Front Port 2
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 2
   - name: Front Port 3
   - name: Front Port 3
     type: 8p8c
     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
     rear_port: Rear Port 3
 module-bays:
 module-bays:
   - name: Module Bay 1
   - name: Module Bay 1
@@ -1477,8 +1487,12 @@ module-bays:
         self.assertEqual(module_type.frontporttemplates.count(), 3)
         self.assertEqual(module_type.frontporttemplates.count(), 3)
         fp1 = FrontPortTemplate.objects.first()
         fp1 = FrontPortTemplate.objects.first()
         self.assertEqual(fp1.name, 'Front Port 1')
         self.assertEqual(fp1.name, 'Front Port 1')
-        self.assertEqual(fp1.rear_port, rp1)
-        self.assertEqual(fp1.rear_port_position, 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)
         self.assertEqual(module_type.modulebaytemplates.count(), 3)
         mb1 = ModuleBayTemplate.objects.first()
         mb1 = ModuleBayTemplate.objects.first()
@@ -1770,7 +1784,7 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-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 1'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
@@ -1778,35 +1792,33 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'),
             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)
+        PortTemplateMapping.objects.bulk_create([
+            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 = {
         cls.form_data = {
             'device_type': devicetype.pk,
             'device_type': devicetype.pk,
             'name': 'Front Port X',
             'name': 'Front Port X',
             'type': PortTypeChoices.TYPE_8P8C,
             '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 = {
         cls.bulk_create_data = {
             'device_type': devicetype.pk,
             'device_type': devicetype.pk,
             'name': 'Front Port [4-6]',
             'name': 'Front Port [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
             '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 = {
         cls.bulk_edit_data = {
@@ -2276,11 +2288,16 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
         front_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)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            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})
         url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
@@ -3065,7 +3082,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
 
 
-        rearports = (
+        rear_ports = (
             RearPort(device=device, name='Rear Port 1'),
             RearPort(device=device, name='Rear Port 1'),
             RearPort(device=device, name='Rear Port 2'),
             RearPort(device=device, name='Rear Port 2'),
             RearPort(device=device, name='Rear Port 3'),
             RearPort(device=device, name='Rear Port 3'),
@@ -3073,14 +3090,19 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 5'),
             RearPort(device=device, name='Rear Port 5'),
             RearPort(device=device, name='Rear Port 6'),
             RearPort(device=device, name='Rear Port 6'),
         )
         )
-        RearPort.objects.bulk_create(rearports)
+        RearPort.objects.bulk_create(rear_ports)
 
 
         front_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)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            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')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
@@ -3088,8 +3110,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'device': device.pk,
             'device': device.pk,
             'name': 'Front Port X',
             'name': 'Front Port X',
             'type': PortTypeChoices.TYPE_8P8C,
             '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',
             'description': 'New description',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
@@ -3098,7 +3120,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'device': device.pk,
             'device': device.pk,
             'name': 'Front Port [4-6]',
             'name': 'Front Port [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
             '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',
             'description': 'New description',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
@@ -3109,10 +3132,10 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = (
         cls.csv_update_data = (

+ 33 - 0
netbox/dcim/utils.py

@@ -83,3 +83,36 @@ def update_interface_bridges(device, interface_templates, module=None):
             )
             )
             interface.full_clean()
             interface.full_clean()
             interface.save()
             interface.save()
+
+
+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, RearPort
+
+    templates = device_type.port_mappings.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 = 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(
+                device_id=front_port.device_id,
+                front_port=front_port,
+                front_port_position=template.front_port_position,
+                rear_port=rear_port,
+                rear_port_position=template.rear_port_position,
+            )
+        )
+    PortMapping.objects.bulk_create(mappings)

+ 13 - 0
netbox/dcim/views.py

@@ -42,6 +42,7 @@ from wireless.models import WirelessLAN
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .models import *
 from .models import *
+from .models.device_components import PortMapping
 from .object_actions import BulkAddComponents, BulkDisconnect
 from .object_actions import BulkAddComponents, BulkDisconnect
 
 
 CABLE_TERMINATION_TYPES = {
 CABLE_TERMINATION_TYPES = {
@@ -1515,6 +1516,7 @@ class DeviceTypeImportView(generic.BulkImportView):
         'interfaces': forms.InterfaceTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
+        'port-mappings': forms.PortTemplateMappingImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
         'device-bays': forms.DeviceBayTemplateImportForm,
         'device-bays': forms.DeviceBayTemplateImportForm,
         'inventory-items': forms.InventoryItemTemplateImportForm,
         'inventory-items': forms.InventoryItemTemplateImportForm,
@@ -1819,6 +1821,7 @@ class ModuleTypeImportView(generic.BulkImportView):
         'interfaces': forms.InterfaceTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
+        'port-mappings': forms.PortTemplateMappingImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
     }
     }
 
 
@@ -3242,6 +3245,11 @@ class FrontPortListView(generic.ObjectListView):
 class FrontPortView(generic.ObjectView):
 class FrontPortView(generic.ObjectView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
 
 
+    def get_extra_context(self, request, instance):
+        return {
+            'rear_port_mappings': PortMapping.objects.filter(front_port=instance).prefetch_related('rear_port'),
+        }
+
 
 
 @register_model_view(FrontPort, 'add', detail=False)
 @register_model_view(FrontPort, 'add', detail=False)
 class FrontPortCreateView(generic.ComponentCreateView):
 class FrontPortCreateView(generic.ComponentCreateView):
@@ -3313,6 +3321,11 @@ class RearPortListView(generic.ObjectListView):
 class RearPortView(generic.ObjectView):
 class RearPortView(generic.ObjectView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
 
 
+    def get_extra_context(self, request, instance):
+        return {
+            'front_port_mappings': PortMapping.objects.filter(rear_port=instance).prefetch_related('front_port'),
+        }
+
 
 
 @register_model_view(RearPort, 'add', detail=False)
 @register_model_view(RearPort, 'add', detail=False)
 class RearPortCreateView(generic.ComponentCreateView):
 class RearPortCreateView(generic.ComponentCreateView):

+ 3 - 3
netbox/netbox/views/generic/object_views.py

@@ -1,6 +1,5 @@
 import logging
 import logging
 from collections import defaultdict
 from collections import defaultdict
-from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.db import router, transaction
 from django.db import router, transaction
@@ -564,7 +563,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
         if form.is_valid():
         if form.is_valid():
             changelog_message = form.cleaned_data.pop('changelog_message', '')
             changelog_message = form.cleaned_data.pop('changelog_message', '')
             new_components = []
             new_components = []
-            data = deepcopy(request.POST)
+            data = request.POST.copy()
             pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
             pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
 
 
             for i in range(pattern_count):
             for i in range(pattern_count):
@@ -573,7 +572,8 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         data[field_name] = form.cleaned_data[field_name][i]
                         data[field_name] = form.cleaned_data[field_name][i]
 
 
                 if hasattr(form, 'get_iterative_data'):
                 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)
                 component_form = self.model_form(data)
 
 

+ 26 - 7
netbox/templates/dcim/frontport.html

@@ -47,12 +47,8 @@
                       </td>
                       </td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
-                        <th scope="row">{% trans "Rear Port" %}</th>
-                        <td>{{ object.rear_port|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Rear Port Position" %}</th>
-                        <td>{{ object.rear_port_position }}</td>
+                        <th scope="row">{% trans "Positions" %}</th>
+                        <td>{{ object.positions }}</td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">{% trans "Description" %}</th>
                         <th scope="row">{% trans "Description" %}</th>
@@ -62,6 +58,7 @@
             </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/tags.html' %}
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-12 col-md-6">
         <div class="col col-12 col-md-6">
@@ -126,7 +123,29 @@
                     </div>
                     </div>
                 {% endif %}
                 {% endif %}
             </div>
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
+            <div class="card">
+              <h2 class="card-header">{% trans "Port Mappings" %}</h2>
+              <table class="table table-hover">
+                {% if rear_port_mappings %}
+                  <thead>
+                    <tr>
+                      <th>{% trans "Position" %}</th>
+                      <th>{% trans "Rear Port" %}</th>
+                    </tr>
+                  </thead>
+                {% endif %}
+                {% for mapping in rear_port_mappings %}
+                  <tr>
+                    <td>{{ mapping.front_port_position }}</td>
+                    <td>
+                      <a href="{{ mapping.rear_port.get_absolute_url }}">{{ mapping.rear_port }}:{{ mapping.rear_port_position }}</a>
+                    </td>
+                  </tr>
+                {% empty %}
+                  {% trans "No mappings defined" %}
+                {% endfor %}
+              </table>
+            </div>
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

+ 24 - 1
netbox/templates/dcim/rearport.html

@@ -58,6 +58,7 @@
             </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/tags.html' %}
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-12 col-md-6">
         <div class="col col-12 col-md-6">
@@ -116,7 +117,29 @@
                     </div>
                     </div>
                 {% endif %}
                 {% endif %}
             </div>
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
+            <div class="card">
+              <h2 class="card-header">{% trans "Port Mappings" %}</h2>
+              <table class="table table-hover">
+                {% if front_port_mappings %}
+                  <thead>
+                    <tr>
+                      <th>{% trans "Position" %}</th>
+                      <th>{% trans "Front Port" %}</th>
+                    </tr>
+                  </thead>
+                {% endif %}
+                {% for mapping in front_port_mappings %}
+                  <tr>
+                    <td>{{ mapping.rear_port_position }}</td>
+                    <td>
+                      <a href="{{ mapping.front_port.get_absolute_url }}">{{ mapping.front_port }}:{{ mapping.front_port_position }}</a>
+                    </td>
+                  </tr>
+                {% empty %}
+                  {% trans "No mappings defined" %}
+                {% endfor %}
+              </table>
+            </div>
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

+ 1 - 1
netbox/utilities/relations.py

@@ -13,7 +13,7 @@ def get_related_models(model, ordered=True):
     related_models = [
     related_models = [
         (field.related_model, field.remote_field.name)
         (field.related_model, field.remote_field.name)
         for field in model._meta.related_objects
         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:
     if ordered: