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

Remove link peer fields from cable termination models

jeremystretch 3 лет назад
Родитель
Сommit
3362bc3106

+ 1 - 1
netbox/circuits/api/serializers.py

@@ -109,6 +109,6 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
         model = CircuitTermination
         fields = [
             'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
-            'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
+            'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type',
             '_occupied', 'created', 'last_updated',
         ]

+ 1 - 1
netbox/circuits/api/views.py

@@ -58,7 +58,7 @@ class CircuitViewSet(NetBoxModelViewSet):
 
 class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = CircuitTermination.objects.prefetch_related(
-        'circuit', 'site', 'provider_network', 'cable'
+        'circuit', 'site', 'provider_network', 'cable__terminations'
     )
     serializer_class = serializers.CircuitTerminationSerializer
     filterset_class = filtersets.CircuitTerminationFilterSet

+ 1 - 1
netbox/circuits/filtersets.py

@@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CabledObjectFilter
 
     class Meta:
         model = CircuitTermination
-        fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
+        fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end']
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 16 - 0
netbox/circuits/migrations/0036_new_cabling_models.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0035_provider_asns'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittermination',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+    ]

+ 20 - 0
netbox/circuits/migrations/0037_cabling_cleanup.py

@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0036_new_cabling_models'),
+        ('dcim', '0157_populate_cable_ends'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='circuittermination',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='circuittermination',
+            name='_link_peer_type',
+        ),
+    ]

+ 32 - 20
netbox/dcim/api/serializers.py

@@ -27,25 +27,37 @@ from .nested_serializers import *
 
 
 class LinkTerminationSerializer(serializers.ModelSerializer):
-    link_peer_type = serializers.SerializerMethodField(read_only=True)
-    link_peer = serializers.SerializerMethodField(read_only=True)
+    link_peers_type = serializers.SerializerMethodField(read_only=True)
+    link_peers = serializers.SerializerMethodField(read_only=True)
     _occupied = serializers.SerializerMethodField(read_only=True)
 
-    def get_link_peer_type(self, obj):
-        if obj._link_peer is not None:
-            return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
+    def get_link_peers_type(self, obj):
+        """
+        Return the type of the peer link terminations, or None.
+        """
+        if not obj.cable:
+            return None
+
+        if obj.link_peers:
+            return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}'
+
         return None
 
-    @swagger_serializer_method(serializer_or_field=serializers.DictField)
-    def get_link_peer(self, obj):
+    @swagger_serializer_method(serializer_or_field=serializers.ListField)
+    def get_link_peers(self, obj):
         """
         Return the appropriate serializer for the link termination model.
         """
-        if obj._link_peer is not None:
-            serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
+        if not obj.cable:
+            return []
+
+        # Return serialized peer termination objects
+        if obj.link_peers:
+            serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested')
             context = {'request': self.context['request']}
-            return serializer(obj._link_peer, context=context).data
-        return None
+            return serializer(obj.link_peers, context=context, many=True).data
+
+        return []
 
     @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
     def get__occupied(self, obj):
@@ -96,7 +108,7 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
         if endpoints:
             return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
 
-    @swagger_serializer_method(serializer_or_field=serializers.DictField)
+    @swagger_serializer_method(serializer_or_field=serializers.ListField)
     def get_connected_endpoints(self, obj):
         """
         Return the appropriate serializer for the type of connected object.
@@ -715,7 +727,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ
         model = ConsoleServerPort
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
-            'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoints', 'connected_endpoints_type',
+            'mark_connected', 'cable', 'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type',
             'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
 
@@ -743,7 +755,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
         model = ConsolePort
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
-            'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoints', 'connected_endpoints_type',
+            'mark_connected', 'cable', 'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type',
             'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
 
@@ -777,7 +789,7 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
         model = PowerOutlet
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
-            'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoints',
+            'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type', 'connected_endpoints',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
@@ -801,7 +813,7 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         model = PowerPort
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
-            'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoints',
+            'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type', 'connected_endpoints',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
@@ -847,7 +859,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
             'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
-            'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoints',
+            'cable', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'connected_endpoints',
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
@@ -880,7 +892,7 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
         model = RearPort
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
-            'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
+            'mark_connected', 'cable', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
 
@@ -911,7 +923,7 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
         model = FrontPort
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
-            'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags',
+            'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peers', 'link_peers_type', 'tags',
             'custom_fields', 'created', 'last_updated', '_occupied',
         ]
 
@@ -1193,7 +1205,7 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         model = PowerFeed
         fields = [
             'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
-            'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
+            'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peers', 'link_peers_type',
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',
         ]

+ 9 - 9
netbox/dcim/api/views.py

@@ -546,7 +546,7 @@ class ModuleViewSet(NetBoxModelViewSet):
 
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsolePort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filtersets.ConsolePortFilterSet
@@ -555,7 +555,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsoleServerPort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -564,7 +564,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerPort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filtersets.PowerPortFilterSet
@@ -573,7 +573,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related(
-        'device', 'module__module_bay', '_path', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filtersets.PowerOutletFilterSet
@@ -582,8 +582,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
-        'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable', '_link_peer',
-        'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
+        'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
+        'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
     )
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filtersets.InterfaceFilterSet
@@ -592,7 +592,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = FrontPort.objects.prefetch_related(
-        'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags'
+        'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filtersets.FrontPortFilterSet
@@ -601,7 +601,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 
 class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = RearPort.objects.prefetch_related(
-        'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags'
+        'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.RearPortSerializer
     filterset_class = filtersets.RearPortFilterSet
@@ -691,7 +691,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
 
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerFeed.objects.prefetch_related(
-        'power_panel', 'rack', '_path', 'cable', '_link_peer', 'tags'
+        'power_panel', 'rack', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.PowerFeedSerializer
     filterset_class = filtersets.PowerFeedFilterSet

+ 2 - 1
netbox/dcim/choices.py

@@ -1226,7 +1226,8 @@ class CableEndChoices(ChoiceSet):
 
     CHOICES = (
         (SIDE_A, 'A'),
-        (SIDE_B, 'B')
+        (SIDE_B, 'B'),
+        ('', ''),
     )
 
 

+ 10 - 8
netbox/dcim/filtersets.py

@@ -1141,7 +1141,7 @@ class ConsolePortFilterSet(
 
     class Meta:
         model = ConsolePort
-        fields = ['id', 'name', 'label', 'description']
+        fields = ['id', 'name', 'label', 'description', 'cable_end']
 
 
 class ConsoleServerPortFilterSet(
@@ -1157,7 +1157,7 @@ class ConsoleServerPortFilterSet(
 
     class Meta:
         model = ConsoleServerPort
-        fields = ['id', 'name', 'label', 'description']
+        fields = ['id', 'name', 'label', 'description', 'cable_end']
 
 
 class PowerPortFilterSet(
@@ -1173,7 +1173,7 @@ class PowerPortFilterSet(
 
     class Meta:
         model = PowerPort
-        fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
+        fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end']
 
 
 class PowerOutletFilterSet(
@@ -1193,7 +1193,7 @@ class PowerOutletFilterSet(
 
     class Meta:
         model = PowerOutlet
-        fields = ['id', 'name', 'label', 'feed_leg', 'description']
+        fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
 
 
 class InterfaceFilterSet(
@@ -1273,7 +1273,7 @@ class InterfaceFilterSet(
         model = Interface
         fields = [
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
-            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
+            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
         ]
 
     def filter_device(self, queryset, name, value):
@@ -1336,7 +1336,7 @@ class FrontPortFilterSet(
 
     class Meta:
         model = FrontPort
-        fields = ['id', 'name', 'label', 'type', 'color', 'description']
+        fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
 
 
 class RearPortFilterSet(
@@ -1351,7 +1351,7 @@ class RearPortFilterSet(
 
     class Meta:
         model = RearPort
-        fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
+        fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end']
 
 
 class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@@ -1679,7 +1679,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
 
     class Meta:
         model = PowerFeed
-        fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization']
+        fields = [
+            'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
+        ]
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 0 - 30
netbox/dcim/migrations/0154_cabletermination.py

@@ -1,30 +0,0 @@
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('contenttypes', '0002_remove_content_type_name'),
-        ('dcim', '0153_created_datetimefield'),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name='CableTermination',
-            fields=[
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
-                ('cable_end', models.CharField(max_length=1)),
-                ('termination_id', models.PositiveBigIntegerField()),
-                ('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')),
-                ('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
-            ],
-            options={
-                'ordering': ('cable', 'cable_end', 'pk'),
-            },
-        ),
-        migrations.AddConstraint(
-            model_name='cabletermination',
-            constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='unique_termination'),
-        ),
-    ]

+ 91 - 0
netbox/dcim/migrations/0154_new_cabling_models.py

@@ -0,0 +1,91 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0153_created_datetimefield'),
+    ]
+
+    operations = [
+
+        # Create CableTermination model
+        migrations.CreateModel(
+            name='CableTermination',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('cable_end', models.CharField(max_length=1)),
+                ('termination_id', models.PositiveBigIntegerField()),
+                ('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')),
+                ('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+            ],
+            options={
+                'ordering': ('cable', 'cable_end', 'pk'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='cabletermination',
+            constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='unique_termination'),
+        ),
+
+        # Update CablePath model
+        migrations.RenameField(
+            model_name='cablepath',
+            old_name='path',
+            new_name='_nodes',
+        ),
+        migrations.AddField(
+            model_name='cablepath',
+            name='path',
+            field=models.JSONField(default=list),
+        ),
+        migrations.AddField(
+            model_name='cablepath',
+            name='is_complete',
+            field=models.BooleanField(default=False),
+        ),
+
+        # Add cable_end field to cable termination models
+        migrations.AddField(
+            model_name='consoleport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='cable_end',
+            field=models.CharField(blank=True, max_length=1),
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0155_populate_cable_terminations.py

@@ -40,7 +40,7 @@ def populate_cable_terminations(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0154_cabletermination'),
+        ('dcim', '0154_new_cabling_models'),
     ]
 
     operations = [

+ 0 - 37
netbox/dcim/migrations/0156_cable_remove_terminations.py

@@ -1,37 +0,0 @@
-# Generated by Django 4.0.4 on 2022-04-29 14:05
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('dcim', '0155_populate_cable_terminations'),
-    ]
-
-    operations = [
-        migrations.AlterModelOptions(
-            name='cable',
-            options={'ordering': ('pk',)},
-        ),
-        migrations.AlterUniqueTogether(
-            name='cable',
-            unique_together=set(),
-        ),
-        migrations.RemoveField(
-            model_name='cable',
-            name='termination_a_id',
-        ),
-        migrations.RemoveField(
-            model_name='cable',
-            name='termination_a_type',
-        ),
-        migrations.RemoveField(
-            model_name='cable',
-            name='termination_b_id',
-        ),
-        migrations.RemoveField(
-            model_name='cable',
-            name='termination_b_type',
-        ),
-    ]

+ 1 - 1
netbox/dcim/migrations/0158_cablepath_populate_path.py → netbox/dcim/migrations/0156_populate_cable_paths.py

@@ -39,7 +39,7 @@ def populate_cable_paths(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('dcim', '0157_cablepath'),
+        ('dcim', '0155_populate_cable_terminations'),
     ]
 
     operations = [

+ 0 - 28
netbox/dcim/migrations/0157_cablepath.py

@@ -1,28 +0,0 @@
-import dcim.fields
-import django.contrib.postgres.fields
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('dcim', '0156_cable_remove_terminations'),
-    ]
-
-    operations = [
-        migrations.RenameField(
-            model_name='cablepath',
-            old_name='path',
-            new_name='_nodes',
-        ),
-        migrations.AddField(
-            model_name='cablepath',
-            name='path',
-            field=models.JSONField(default=list),
-        ),
-        migrations.AddField(
-            model_name='cablepath',
-            name='is_complete',
-            field=models.BooleanField(default=False),
-        ),
-    ]

+ 42 - 0
netbox/dcim/migrations/0157_populate_cable_ends.py

@@ -0,0 +1,42 @@
+from django.db import migrations
+
+
+def populate_cable_terminations(apps, schema_editor):
+    Cable = apps.get_model('dcim', 'Cable')
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+
+    cable_termination_models = (
+        apps.get_model('dcim', 'ConsolePort'),
+        apps.get_model('dcim', 'ConsoleServerPort'),
+        apps.get_model('dcim', 'PowerPort'),
+        apps.get_model('dcim', 'PowerOutlet'),
+        apps.get_model('dcim', 'Interface'),
+        apps.get_model('dcim', 'FrontPort'),
+        apps.get_model('dcim', 'RearPort'),
+        apps.get_model('dcim', 'PowerFeed'),
+        apps.get_model('circuits', 'CircuitTermination'),
+    )
+
+    for model in cable_termination_models:
+        ct = ContentType.objects.get_for_model(model)
+        model.objects.filter(
+            id__in=Cable.objects.filter(termination_a_type=ct).values_list('termination_a_id', flat=True)
+        ).update(cable_end='A')
+        model.objects.filter(
+            id__in=Cable.objects.filter(termination_b_type=ct).values_list('termination_b_id', flat=True)
+        ).update(cable_end='B')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0036_new_cabling_models'),
+        ('dcim', '0156_populate_cable_paths'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=populate_cable_terminations,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 126 - 0
netbox/dcim/migrations/0158_cabling_cleanup.py

@@ -0,0 +1,126 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0157_populate_cable_ends'),
+    ]
+
+    operations = [
+
+        # Remove old fields from Cable
+        migrations.AlterModelOptions(
+            name='cable',
+            options={'ordering': ('pk',)},
+        ),
+        migrations.AlterUniqueTogether(
+            name='cable',
+            unique_together=set(),
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='termination_a_id',
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='termination_a_type',
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='termination_b_id',
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='termination_b_type',
+        ),
+
+        # Remove old fields from CablePath
+        migrations.AlterUniqueTogether(
+            name='cablepath',
+            unique_together=set(),
+        ),
+        migrations.RemoveField(
+            model_name='cablepath',
+            name='destination_id',
+        ),
+        migrations.RemoveField(
+            model_name='cablepath',
+            name='destination_type',
+        ),
+        migrations.RemoveField(
+            model_name='cablepath',
+            name='origin_id',
+        ),
+        migrations.RemoveField(
+            model_name='cablepath',
+            name='origin_type',
+        ),
+
+        # Remove link peer type/ID fields from cable termination models
+        migrations.RemoveField(
+            model_name='consoleport',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='consoleport',
+            name='_link_peer_type',
+        ),
+        migrations.RemoveField(
+            model_name='consoleserverport',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='consoleserverport',
+            name='_link_peer_type',
+        ),
+        migrations.RemoveField(
+            model_name='frontport',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='frontport',
+            name='_link_peer_type',
+        ),
+        migrations.RemoveField(
+            model_name='interface',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='interface',
+            name='_link_peer_type',
+        ),
+        migrations.RemoveField(
+            model_name='powerfeed',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='powerfeed',
+            name='_link_peer_type',
+        ),
+        migrations.RemoveField(
+            model_name='poweroutlet',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='poweroutlet',
+            name='_link_peer_type',
+        ),
+        migrations.RemoveField(
+            model_name='powerport',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='powerport',
+            name='_link_peer_type',
+        ),
+        migrations.RemoveField(
+            model_name='rearport',
+            name='_link_peer_id',
+        ),
+        migrations.RemoveField(
+            model_name='rearport',
+            name='_link_peer_type',
+        ),
+
+    ]

+ 0 - 33
netbox/dcim/migrations/0159_cablepath_remove_origin_destination.py

@@ -1,33 +0,0 @@
-# Generated by Django 4.0.4 on 2022-05-03 14:50
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('dcim', '0158_cablepath_populate_path'),
-    ]
-
-    operations = [
-        migrations.AlterUniqueTogether(
-            name='cablepath',
-            unique_together=set(),
-        ),
-        migrations.RemoveField(
-            model_name='cablepath',
-            name='destination_id',
-        ),
-        migrations.RemoveField(
-            model_name='cablepath',
-            name='destination_type',
-        ),
-        migrations.RemoveField(
-            model_name='cablepath',
-            name='origin_id',
-        ),
-        migrations.RemoveField(
-            model_name='cablepath',
-            name='origin_type',
-        ),
-    ]

+ 4 - 1
netbox/dcim/models/cables.py

@@ -307,7 +307,10 @@ class CableTermination(models.Model):
 
         # Set the cable on the terminating object
         termination_model = self.termination._meta.model
-        termination_model.objects.filter(pk=self.termination_id).update(cable=self.cable)
+        termination_model.objects.filter(pk=self.termination_id).update(
+            cable=self.cable,
+            cable_end=self.cable_end
+        )
 
     def delete(self, *args, **kwargs):
 

+ 17 - 22
netbox/dcim/models/device_components.py

@@ -1,6 +1,6 @@
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import Sum
@@ -105,12 +105,7 @@ class ModularComponentModel(ComponentModel):
 
 class LinkTermination(models.Model):
     """
-    An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
-    include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
-    reference the attached Cable or WirelessLink instance, respectively.
-
-    `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
-    shortcut to referencing `instance.link.termination_b`, for example.
+    An abstract model inherited by all models to which a Cable can terminate.
     """
     cable = models.ForeignKey(
         to='dcim.Cable',
@@ -119,20 +114,10 @@ class LinkTermination(models.Model):
         blank=True,
         null=True
     )
-    _link_peer_type = models.ForeignKey(
-        to=ContentType,
-        on_delete=models.SET_NULL,
-        related_name='+',
+    cable_end = models.CharField(
+        max_length=1,
         blank=True,
-        null=True
-    )
-    _link_peer_id = models.PositiveBigIntegerField(
-        blank=True,
-        null=True
-    )
-    _link_peer = GenericForeignKey(
-        ct_field='_link_peer_type',
-        fk_field='_link_peer_id'
+        choices=CableEndChoices
     )
     mark_connected = models.BooleanField(
         default=False,
@@ -145,13 +130,23 @@ class LinkTermination(models.Model):
     def clean(self):
         super().clean()
 
+        if self.cable and not self.cable_end:
+            raise ValidationError({
+                "cable_end": "Must specify cable end (A or B) when attaching a cable."
+            })
+
         if self.mark_connected and self.cable_id:
             raise ValidationError({
                 "mark_connected": "Cannot mark as connected with a cable attached."
             })
 
-    def get_link_peer(self):
-        return self._link_peer
+    @property
+    def link_peers(self):
+        # TODO: Support WirelessLinks
+        if not self.cable:
+            return []
+        peer_terminations = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
+        return [ct.termination for ct in peer_terminations]
 
     @property
     def _occupied(self):

+ 1 - 1
netbox/dcim/signals.py

@@ -132,4 +132,4 @@ def nullify_connected_endpoints(instance, **kwargs):
     Disassociate the Cable from the termination object.
     """
     model = instance.termination_type.model_class()
-    model.objects.filter(pk=instance.termination_id).update(_link_peer_type=None, _link_peer_id=None)
+    model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')

+ 8 - 4
netbox/dcim/tests/test_models.py

@@ -503,8 +503,12 @@ class CableTestCase(TestCase):
         """
         self.interface1.refresh_from_db()
         self.interface2.refresh_from_db()
-        self.assertEqual(self.interface1._link_peer, self.interface2)
-        self.assertEqual(self.interface2._link_peer, self.interface1)
+        self.assertEqual(self.interface1.cable, self.cable)
+        self.assertEqual(self.interface2.cable, self.cable)
+        self.assertEqual(self.interface1.cable_end, 'A')
+        self.assertEqual(self.interface2.cable_end, 'B')
+        self.assertEqual(self.interface1.link_peers, [self.interface2])
+        self.assertEqual(self.interface2.link_peers, [self.interface1])
 
     def test_cable_deletion(self):
         """
@@ -516,10 +520,10 @@ class CableTestCase(TestCase):
         self.assertNotEqual(str(self.cable), '#None')
         interface1 = Interface.objects.get(pk=self.interface1.pk)
         self.assertIsNone(interface1.cable)
-        self.assertIsNone(interface1._link_peer)
+        self.assertListEqual(interface1.link_peers, [])
         interface2 = Interface.objects.get(pk=self.interface2.pk)
         self.assertIsNone(interface2.cable)
-        self.assertIsNone(interface2._link_peer)
+        self.assertListEqual(interface2.link_peers, [])
 
     def test_cable_validates_same_parent_object(self):
         """

+ 4 - 5
netbox/templates/circuits/inc/circuit_termination.html

@@ -44,16 +44,15 @@
                   <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
                   <span class="text-muted">Marked as connected</span>
                 {% elif termination.cable %}
-                  <a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
-                  {% with peer=termination.get_link_peer %}
-                    to
+                  <a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> to
+                  {% for peer in termination.link_peers %}
                     {% if peer.device %}
                       {{ peer.device|linkify }}<br/>
                     {% elif peer.circuit %}
                       {{ peer.circuit|linkify }}<br/>
                     {% endif %}
-                    {{ peer|linkify }}
-                  {% endwith %}
+                    {{ peer|linkify }}{% if not forloop.last %},{% endif %}
+                  {% endfor %}
                   <div class="mt-1">
                     <a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
                       <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> Trace

+ 2 - 12
netbox/wireless/signals.py

@@ -25,12 +25,10 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs):
     if instance.interface_a.wireless_link != instance:
         logger.debug(f"Updating interface A for wireless link {instance}")
         instance.interface_a.wireless_link = instance
-        instance.interface_a._link_peer = instance.interface_b
         instance.interface_a.save()
     if instance.interface_b.cable != instance:
         logger.debug(f"Updating interface B for wireless link {instance}")
         instance.interface_b.wireless_link = instance
-        instance.interface_b._link_peer = instance.interface_a
         instance.interface_b.save()
 
     # Create/update cable paths
@@ -48,18 +46,10 @@ def nullify_connected_interfaces(instance, **kwargs):
 
     if instance.interface_a is not None:
         logger.debug(f"Nullifying interface A for wireless link {instance}")
-        Interface.objects.filter(pk=instance.interface_a.pk).update(
-            wireless_link=None,
-            _link_peer_type=None,
-            _link_peer_id=None
-        )
+        Interface.objects.filter(pk=instance.interface_a.pk).update(wireless_link=None)
     if instance.interface_b is not None:
         logger.debug(f"Nullifying interface B for wireless link {instance}")
-        Interface.objects.filter(pk=instance.interface_b.pk).update(
-            wireless_link=None,
-            _link_peer_type=None,
-            _link_peer_id=None
-        )
+        Interface.objects.filter(pk=instance.interface_b.pk).update(wireless_link=None)
 
     # Delete and retrace any dependent cable paths
     for cablepath in CablePath.objects.filter(_nodes__contains=instance):