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

Merge pull request #9615 from netbox-community/9102-cabling

Closes #9102: Add support for multi-termination cable ends
Jeremy Stretch 3 лет назад
Родитель
Сommit
6c9f2734a2
65 измененных файлов с 2928 добавлено и 2077 удалено
  1. 85 1
      docs/release-notes/version-3.3.md
  2. 5 6
      netbox/circuits/api/serializers.py
  3. 1 1
      netbox/circuits/api/views.py
  4. 3 3
      netbox/circuits/filtersets.py
  5. 2 1
      netbox/circuits/graphql/types.py
  6. 0 18
      netbox/circuits/migrations/0036_circuit_termination_date.py
  7. 6 2
      netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py
  8. 16 0
      netbox/circuits/migrations/0037_new_cabling_models.py
  9. 20 0
      netbox/circuits/migrations/0038_cabling_cleanup.py
  10. 2 2
      netbox/circuits/models/circuits.py
  11. 1 1
      netbox/circuits/signals.py
  12. 1 1
      netbox/circuits/tests/test_filtersets.py
  13. 1 1
      netbox/circuits/tests/test_views.py
  14. 1 2
      netbox/circuits/urls.py
  15. 127 121
      netbox/dcim/api/serializers.py
  16. 1 0
      netbox/dcim/api/urls.py
  17. 38 39
      netbox/dcim/api/views.py
  18. 16 0
      netbox/dcim/choices.py
  19. 2 0
      netbox/dcim/constants.py
  20. 62 38
      netbox/dcim/filtersets.py
  21. 169 277
      netbox/dcim/forms/connections.py
  22. 14 3
      netbox/dcim/forms/filtersets.py
  23. 5 0
      netbox/dcim/graphql/mixins.py
  24. 17 8
      netbox/dcim/graphql/types.py
  25. 1 1
      netbox/dcim/management/commands/trace_paths.py
  26. 95 0
      netbox/dcim/migrations/0157_new_cabling_models.py
  27. 76 0
      netbox/dcim/migrations/0158_populate_cable_terminations.py
  28. 50 0
      netbox/dcim/migrations/0159_populate_cable_paths.py
  29. 42 0
      netbox/dcim/migrations/0160_populate_cable_ends.py
  30. 134 0
      netbox/dcim/migrations/0161_cabling_cleanup.py
  31. 423 297
      netbox/dcim/models/cables.py
  32. 115 93
      netbox/dcim/models/device_components.py
  33. 2 2
      netbox/dcim/models/power.py
  34. 10 10
      netbox/dcim/models/racks.py
  35. 37 55
      netbox/dcim/signals.py
  36. 228 170
      netbox/dcim/svg/cables.py
  37. 81 28
      netbox/dcim/tables/cables.py
  38. 36 34
      netbox/dcim/tables/template_code.py
  39. 19 19
      netbox/dcim/tests/test_api.py
  40. 536 210
      netbox/dcim/tests/test_cablepaths.py
  41. 55 37
      netbox/dcim/tests/test_filtersets.py
  42. 24 68
      netbox/dcim/tests/test_models.py
  43. 14 15
      netbox/dcim/tests/test_views.py
  44. 1 8
      netbox/dcim/urls.py
  45. 15 11
      netbox/dcim/utils.py
  46. 43 67
      netbox/dcim/views.py
  47. 2 2
      netbox/netbox/middleware.py
  48. 3 5
      netbox/netbox/views/__init__.py
  49. 1 1
      netbox/project-static/dist/cable_trace.css
  50. 5 1
      netbox/project-static/styles/cable-trace.scss
  51. 8 9
      netbox/templates/circuits/inc/circuit_termination.html
  52. 71 77
      netbox/templates/dcim/cable.html
  53. 0 186
      netbox/templates/dcim/cable_connect.html
  54. 123 3
      netbox/templates/dcim/cable_edit.html
  55. 3 18
      netbox/templates/dcim/consoleport.html
  56. 3 18
      netbox/templates/dcim/consoleserverport.html
  57. 6 6
      netbox/templates/dcim/frontport.html
  58. 0 27
      netbox/templates/dcim/inc/cable_form.html
  59. 55 39
      netbox/templates/dcim/inc/cable_termination.html
  60. 4 12
      netbox/templates/dcim/interface.html
  61. 1 2
      netbox/templates/dcim/powerfeed.html
  62. 1 1
      netbox/templates/dcim/poweroutlet.html
  63. 2 2
      netbox/templates/dcim/powerport.html
  64. 4 4
      netbox/templates/dcim/rearport.html
  65. 4 14
      netbox/wireless/signals.py

+ 85 - 1
docs/release-notes/version-3.3.md

@@ -6,6 +6,28 @@
 
 * Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units.
 * The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
+* Several fields on the cable API serializers have been altered to support multiple-object cable terminations:
+
+| Old Name             | Old Type | New Name              | New Type |
+|----------------------|----------|-----------------------|----------|
+| `termination_a_type` | string   | `a_terminations_type` | string   |
+| `termination_b_type` | string   | `b_terminations_type` | string   |
+| `termination_a_id`   | integer  | _Removed_             | -        |
+| `termination_b_id`   | integer  | _Removed_             | -        |
+| `termination_a`      | object   | `a_terminations`      | list     |
+| `termination_b`      | object   | `b_terminations`      | list     |
+
+* As with the cable model, several API fields on all objects to which cables can be connected (interfaces, circuit terminations, etc.) have been changed:
+
+| Old Name                       | Old Type | New Name                        | New Type |
+|--------------------------------|----------|---------------------------------|----------|
+| `link_peer`                    | object   | `link_peers`                    | list     |
+| `link_peer_type`               | string   | `link_peers_type`               | string   |
+| `connected_endpoint`           | object   | `connected_endpoints`           | list     |
+| `connected_endpoint_type`      | string   | `connected_endpoints_type`      | string   |
+| `connected_endpoint_reachable` | boolean  | `connected_endpoints_reachable` | boolean  |
+
+* The cable path serialization returned by the `/paths/` endpoint for pass-through ports has been simplified, and the following fields removed: `origin_type`, `origin`, `destination_type`, `destination`. (Additionally, `is_complete` has been added.)
 
 ### New Features
 
@@ -19,6 +41,8 @@
 
 #### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
 
+#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))
+
 ### Enhancements
 
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
@@ -55,23 +79,83 @@
 ### REST API Changes
 
 * Added the following endpoints:
+    * `/api/dcim/cable-terminations/`
     * `/api/ipam/l2vpns/`
     * `/api/ipam/l2vpn-terminations/`
 * circuits.Circuit
     * Added optional `termination_date` field
 * circuits.CircuitTermination
-    * Added 'custom_fields' and 'tags' fields
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
+    * Added `custom_fields` and `tags` fields
+* dcim.Cable
+    * `termination_a_type` has been renamed to `a_terminations_type`
+    * `termination_b_type` has been renamed to `b_terminations_type`
+    * `termination_a` renamed to `a_terminations` and now returns a list of objects
+    * `termination_b` renamed to `b_terminations` and now returns a list of objects
+    * `termination_a_id` has been removed
+    * `termination_b_id` has been removed
+* dcim.ConsolePort
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
+* dcim.ConsoleServerPort
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
 * dcim.Device
     * The `position` field has been changed from an integer to a decimal
 * dcim.DeviceType
     * The `u_height` field has been changed from an integer to a decimal
+* dcim.FrontPort
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
 * dcim.Interface
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
     * Added the optional `poe_mode` and `poe_type` fields
     * Added the `l2vpn_termination` read-only field
 * dcim.Location
     * Added required `status` field (default value: `active`)
+* dcim.PowerOutlet
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
+* dcim.PowerFeed
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
+* dcim.PowerPort
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
 * dcim.Rack
     * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
+* dcim.RearPort
+    * `link_peer` has been renamed to `link_peers` and now returns a list of objects
+    * `link_peer_type` has been renamed to `link_peers_type`
+    * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects
+    * `connected_endpoint_type` has been renamed to `connected_endpoints_type`
+    * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable`
 * extras.ConfigContext
     * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
 * extras.CustomField

+ 5 - 6
netbox/circuits/api/serializers.py

@@ -3,11 +3,11 @@ from rest_framework import serializers
 from circuits.choices import CircuitStatusChoices
 from circuits.models import *
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
-from dcim.api.serializers import LinkTerminationSerializer
+from dcim.api.serializers import CabledObjectSerializer
 from ipam.models import ASN
 from ipam.api.nested_serializers import NestedASNSerializer
 from netbox.api import ChoiceField, SerializedPKRelatedField
-from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
+from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
 
@@ -98,17 +98,16 @@ class CircuitSerializer(NetBoxModelSerializer):
         ]
 
 
-class CircuitTerminationSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
+class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer(required=False, allow_null=True)
     provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
-    cable = NestedCableSerializer(read_only=True)
 
     class Meta:
         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',
-            '_occupied', 'tags', 'custom_fields', 'created', 'last_updated',
+            'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
+            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]

+ 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

+ 3 - 3
netbox/circuits/filtersets.py

@@ -1,7 +1,7 @@
 import django_filters
 from django.db.models import Q
 
-from dcim.filtersets import CableTerminationFilterSet
+from dcim.filtersets import CabledObjectFilterSet
 from dcim.models import Region, Site, SiteGroup
 from ipam.models import ASN
 from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
@@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         ).distinct()
 
 
-class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet):
+class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label='Search',
@@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CableTerminationFilterSe
 
     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():

+ 2 - 1
netbox/circuits/graphql/types.py

@@ -1,4 +1,5 @@
 from circuits import filtersets, models
+from dcim.graphql.mixins import CabledObjectMixin
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 
@@ -11,7 +12,7 @@ __all__ = (
 )
 
 
-class CircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
+class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
 
     class Meta:
         model = models.CircuitTermination

+ 0 - 18
netbox/circuits/migrations/0036_circuit_termination_date.py

@@ -1,18 +0,0 @@
-# Generated by Django 4.0.5 on 2022-06-22 18:51
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('circuits', '0035_provider_asns'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='circuit',
-            name='termination_date',
-            field=models.DateField(blank=True, null=True),
-        ),
-    ]

+ 6 - 2
netbox/circuits/migrations/0037_circuittermination_tags_custom_fields.py → netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py

@@ -6,11 +6,15 @@ import taggit.managers
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('extras', '0076_configcontext_locations'),
-        ('circuits', '0036_circuit_termination_date'),
+        ('circuits', '0035_provider_asns'),
     ]
 
     operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='termination_date',
+            field=models.DateField(blank=True, null=True),
+        ),
         migrations.AddField(
             model_name='circuittermination',
             name='custom_field_data',

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

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

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

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

+ 2 - 2
netbox/circuits/models/circuits.py

@@ -4,7 +4,7 @@ from django.db import models
 from django.urls import reverse
 
 from circuits.choices import *
-from dcim.models import LinkTermination
+from dcim.models import CabledObjectModel
 from netbox.models import (
     ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
 )
@@ -149,7 +149,7 @@ class CircuitTermination(
     TagsMixin,
     WebhooksMixin,
     ChangeLoggedModel,
-    LinkTermination
+    CabledObjectModel
 ):
     circuit = models.ForeignKey(
         to='circuits.Circuit',

+ 1 - 1
netbox/circuits/signals.py

@@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs):
     if not raw:
         peer_termination = instance.get_peer_termination()
         if peer_termination:
-            rebuild_paths(peer_termination)
+            rebuild_paths([peer_termination])

+ 1 - 1
netbox/circuits/tests/test_filtersets.py

@@ -360,7 +360,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         ))
         CircuitTermination.objects.bulk_create(circuit_terminations)
 
-        Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
+        Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
 
     def test_term_side(self):
         params = {'term_side': 'A'}

+ 1 - 1
netbox/circuits/tests/test_views.py

@@ -246,7 +246,7 @@ class CircuitTerminationTestCase(
             device=device,
             name='Interface 1'
         )
-        Cable(termination_a=circuittermination, termination_b=interface).save()
+        Cable(a_terminations=[circuittermination], b_terminations=[interface]).save()
 
         response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
         self.assertHttpStatus(response, 200)

+ 1 - 2
netbox/circuits/urls.py

@@ -1,6 +1,6 @@
 from django.urls import path
 
-from dcim.views import CableCreateView, PathTraceView
+from dcim.views import PathTraceView
 from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
 from . import views
 from .models import *
@@ -60,7 +60,6 @@ urlpatterns = [
     path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
     path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
     path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
-    path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
     path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
 
 ]

+ 127 - 121
netbox/dcim/api/serializers.py

@@ -28,58 +28,68 @@ from wireless.models import WirelessLAN
 from .nested_serializers import *
 
 
-class LinkTerminationSerializer(serializers.ModelSerializer):
-    link_peer_type = serializers.SerializerMethodField(read_only=True)
-    link_peer = serializers.SerializerMethodField(read_only=True)
+class CabledObjectSerializer(serializers.ModelSerializer):
+    cable = NestedCableSerializer(read_only=True)
+    cable_end = serializers.CharField(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')
-            context = {'request': self.context['request']}
-            return serializer(obj._link_peer, context=context).data
-        return None
+        if not obj.link_peers:
+            return []
+
+        # Return serialized peer termination objects
+        serializer = get_serializer_for_model(obj.link_peers[0], prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(obj.link_peers, context=context, many=True).data
 
     @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
     def get__occupied(self, obj):
         return obj._occupied
 
 
-class ConnectedEndpointSerializer(serializers.ModelSerializer):
-    connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
-    connected_endpoint = serializers.SerializerMethodField(read_only=True)
-    connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
+class ConnectedEndpointsSerializer(serializers.ModelSerializer):
+    """
+    Legacy serializer for pre-v3.3 connections
+    """
+    connected_endpoints_type = serializers.SerializerMethodField(read_only=True)
+    connected_endpoints = serializers.SerializerMethodField(read_only=True)
+    connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True)
 
-    def get_connected_endpoint_type(self, obj):
-        if obj._path is not None and obj._path.destination is not None:
-            return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
-        return None
+    def get_connected_endpoints_type(self, obj):
+        if endpoints := obj.connected_endpoints:
+            return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}'
 
-    @swagger_serializer_method(serializer_or_field=serializers.DictField)
-    def get_connected_endpoint(self, obj):
+    @swagger_serializer_method(serializer_or_field=serializers.ListField)
+    def get_connected_endpoints(self, obj):
         """
         Return the appropriate serializer for the type of connected object.
         """
-        if obj._path is not None and obj._path.destination is not None:
-            serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
+        if endpoints := obj.connected_endpoints:
+            serializer = get_serializer_for_model(endpoints[0], prefix='Nested')
             context = {'request': self.context['request']}
-            return serializer(obj._path.destination, context=context).data
-        return None
+            return serializer(endpoints, many=True, context=context).data
 
     @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
-    def get_connected_endpoint_reachable(self, obj):
-        if obj._path is not None:
-            return obj._path.is_active
-        return None
+    def get_connected_endpoints_reachable(self, obj):
+        return obj._path and obj._path.is_complete and obj._path.is_active
 
 
 #
@@ -684,7 +694,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
 # Device components
 #
 
-class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -701,18 +711,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ
         allow_null=True,
         required=False
     )
-    cable = NestedCableSerializer(read_only=True)
 
     class Meta:
         model = ConsoleServerPort
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
-            'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
-            'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'last_updated', '_occupied',
         ]
 
 
-class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -729,18 +739,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
         allow_null=True,
         required=False
     )
-    cable = NestedCableSerializer(read_only=True)
 
     class Meta:
         model = ConsolePort
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
-            'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
-            'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+            'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
+            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+            'last_updated', '_occupied',
         ]
 
 
-class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -761,21 +771,18 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
         allow_blank=True,
         required=False
     )
-    cable = NestedCableSerializer(
-        read_only=True
-    )
 
     class Meta:
         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_endpoint',
-            'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', '_occupied',
+            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
+            'created', 'last_updated', '_occupied',
         ]
 
 
-class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -787,19 +794,18 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         allow_blank=True,
         required=False
     )
-    cable = NestedCableSerializer(read_only=True)
 
     class Meta:
         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_endpoint',
-            'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', '_occupied',
+            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
+            'created', 'last_updated', '_occupied',
         ]
 
 
-class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -825,7 +831,6 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
     )
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
-    cable = NestedCableSerializer(read_only=True)
     wireless_link = NestedWirelessLinkSerializer(read_only=True)
     wireless_lans = SerializedPKRelatedField(
         queryset=WirelessLAN.objects.all(),
@@ -842,9 +847,10 @@ 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',
             'poe_mode', 'poe_type', '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', 'l2vpn_termination', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
-            'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
+            'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
+            'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
+            'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
+            'count_fhrp_groups', '_occupied',
         ]
 
     def validate(self, data):
@@ -861,7 +867,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         return super().validate(data)
 
 
-class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
+class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -869,13 +875,12 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
         allow_null=True
     )
     type = ChoiceField(choices=PortTypeChoices)
-    cable = NestedCableSerializer(read_only=True)
 
     class Meta:
         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', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
 
@@ -891,7 +896,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'label']
 
 
-class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
+class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
@@ -900,14 +905,13 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
     )
     type = ChoiceField(choices=PortTypeChoices)
     rear_port = FrontPortRearPortSerializer()
-    cable = NestedCableSerializer(read_only=True)
 
     class Meta:
         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',
-            'custom_fields', 'created', 'last_updated', '_occupied',
+            'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
+            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
 
 
@@ -990,14 +994,10 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
 
 class CableSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
-    termination_a_type = ContentTypeField(
-        queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
-    )
-    termination_b_type = ContentTypeField(
-        queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
-    )
-    termination_a = serializers.SerializerMethodField(read_only=True)
-    termination_b = serializers.SerializerMethodField(read_only=True)
+    a_terminations_type = serializers.SerializerMethodField(read_only=True)
+    b_terminations_type = serializers.SerializerMethodField(read_only=True)
+    a_terminations = serializers.SerializerMethodField(read_only=True)
+    b_terminations = serializers.SerializerMethodField(read_only=True)
     status = ChoiceField(choices=LinkStatusChoices, required=False)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
@@ -1005,33 +1005,46 @@ class CableSerializer(NetBoxModelSerializer):
     class Meta:
         model = Cable
         fields = [
-            'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
-            'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
-            'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'type', 'a_terminations_type', 'a_terminations', 'b_terminations_type',
+            'b_terminations', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', 'custom_fields',
+            'created', 'last_updated',
         ]
 
-    def _get_termination(self, obj, side):
-        """
-        Serialize a nested representation of a termination.
-        """
-        if side.lower() not in ['a', 'b']:
-            raise ValueError("Termination side must be either A or B.")
-        termination = getattr(obj, 'termination_{}'.format(side.lower()))
-        if termination is None:
-            return None
-        serializer = get_serializer_for_model(termination, prefix='Nested')
+    def _get_terminations_type(self, obj, side):
+        assert side in CableEndChoices.values()
+        terms = getattr(obj, f'get_{side.lower()}_terminations')()
+        if terms:
+            ct = ContentType.objects.get_for_model(terms[0])
+            return f"{ct.app_label}.{ct.model}"
+
+    def _get_terminations(self, obj, side):
+        assert side in CableEndChoices.values()
+        terms = getattr(obj, f'get_{side.lower()}_terminations')()
+        if not terms:
+            return []
+
+        termination_type = ContentType.objects.get_for_model(terms[0])
+        serializer = get_serializer_for_model(termination_type.model_class(), prefix='Nested')
         context = {'request': self.context['request']}
-        data = serializer(termination, context=context).data
+        data = serializer(terms, context=context, many=True).data
 
         return data
 
+    @swagger_serializer_method(serializer_or_field=serializers.CharField)
+    def get_a_terminations_type(self, obj):
+        return self._get_terminations_type(obj, CableEndChoices.SIDE_A)
+
+    @swagger_serializer_method(serializer_or_field=serializers.CharField)
+    def get_b_terminations_type(self, obj):
+        return self._get_terminations_type(obj, CableEndChoices.SIDE_B)
+
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
-    def get_termination_a(self, obj):
-        return self._get_termination(obj, 'a')
+    def get_a_terminations(self, obj):
+        return self._get_terminations(obj, CableEndChoices.SIDE_A)
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
-    def get_termination_b(self, obj):
-        return self._get_termination(obj, 'b')
+    def get_b_terminations(self, obj):
+        return self._get_terminations(obj, CableEndChoices.SIDE_B)
 
 
 class TracedCableSerializer(serializers.ModelSerializer):
@@ -1047,46 +1060,40 @@ class TracedCableSerializer(serializers.ModelSerializer):
         ]
 
 
-class CablePathSerializer(serializers.ModelSerializer):
-    origin_type = ContentTypeField(read_only=True)
-    origin = serializers.SerializerMethodField(read_only=True)
-    destination_type = ContentTypeField(read_only=True)
-    destination = serializers.SerializerMethodField(read_only=True)
-    path = serializers.SerializerMethodField(read_only=True)
+class CableTerminationSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail')
+    termination_type = ContentTypeField(
+        queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
+    )
+    termination = serializers.SerializerMethodField(read_only=True)
 
     class Meta:
-        model = CablePath
+        model = CableTermination
         fields = [
-            'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
+            'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
         ]
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
-    def get_origin(self, obj):
-        """
-        Return the appropriate serializer for the origin.
-        """
-        serializer = get_serializer_for_model(obj.origin, prefix='Nested')
+    def get_termination(self, obj):
+        serializer = get_serializer_for_model(obj.termination, prefix='Nested')
         context = {'request': self.context['request']}
-        return serializer(obj.origin, context=context).data
+        return serializer(obj.termination, context=context).data
 
-    @swagger_serializer_method(serializer_or_field=serializers.DictField)
-    def get_destination(self, obj):
-        """
-        Return the appropriate serializer for the destination, if any.
-        """
-        if obj.destination_id is not None:
-            serializer = get_serializer_for_model(obj.destination, prefix='Nested')
-            context = {'request': self.context['request']}
-            return serializer(obj.destination, context=context).data
-        return None
+
+class CablePathSerializer(serializers.ModelSerializer):
+    path = serializers.SerializerMethodField(read_only=True)
+
+    class Meta:
+        model = CablePath
+        fields = ['id', 'path', 'is_active', 'is_complete', 'is_split']
 
     @swagger_serializer_method(serializer_or_field=serializers.ListField)
     def get_path(self, obj):
         ret = []
-        for node in obj.get_path():
-            serializer = get_serializer_for_model(node, prefix='Nested')
+        for nodes in obj.path_objects:
+            serializer = get_serializer_for_model(nodes[0], prefix='Nested')
             context = {'request': self.context['request']}
-            ret.append(serializer(node, context=context).data)
+            ret.append(serializer(nodes, context=context, many=True).data)
         return ret
 
 
@@ -1129,7 +1136,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
         ]
 
 
-class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(
@@ -1153,13 +1160,12 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         choices=PowerFeedPhaseChoices,
         default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
-    cable = NestedCableSerializer(read_only=True)
 
     class Meta:
         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',
-            'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
-            'created', 'last_updated', '_occupied',
+            'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers',
+            'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
+            'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]

+ 1 - 0
netbox/dcim/api/urls.py

@@ -56,6 +56,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
 
 # Cables
 router.register('cables', views.CableViewSet)
+router.register('cable-terminations', views.CableTerminationViewSet)
 
 # Virtual chassis
 router.register('virtual-chassis', views.VirtualChassisViewSet)

+ 38 - 39
netbox/dcim/api/views.py

@@ -13,7 +13,9 @@ from rest_framework.viewsets import ViewSet
 
 from circuits.models import Circuit
 from dcim import filtersets
+from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
+from dcim.svg import CableTraceSVG
 from extras.api.views import ConfigContextQuerySetMixin
 from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -51,37 +53,30 @@ class PathEndpointMixin(object):
         # Initialize the path array
         path = []
 
+        # Render SVG image if requested
         if request.GET.get('render', None) == 'svg':
-            # Render SVG
             try:
-                width = min(int(request.GET.get('width')), 1600)
+                width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
             except (ValueError, TypeError):
-                width = None
-            drawing = obj.get_trace_svg(
-                base_url=request.build_absolute_uri('/'),
-                width=width
-            )
-            return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
+                width = CABLE_TRACE_SVG_DEFAULT_WIDTH
+            drawing = CableTraceSVG(obj, base_url=request.build_absolute_uri('/'), width=width)
+            return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
 
+        # Serialize path objects, iterating over each three-tuple in the path
         for near_end, cable, far_end in obj.trace():
-            if near_end is None:
-                # Split paths
+            if near_end is not None:
+                serializer_a = get_serializer_for_model(near_end[0], prefix='Nested')
+                near_end = serializer_a(near_end, many=True, context={'request': request}).data
+            else:
+                # Path is split; stop here
                 break
-
-            # Serialize each object
-            serializer_a = get_serializer_for_model(near_end, prefix='Nested')
-            x = serializer_a(near_end, context={'request': request}).data
             if cable is not None:
-                y = serializers.TracedCableSerializer(cable, context={'request': request}).data
-            else:
-                y = None
+                cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
             if far_end is not None:
-                serializer_b = get_serializer_for_model(far_end, prefix='Nested')
-                z = serializer_b(far_end, context={'request': request}).data
-            else:
-                z = None
+                serializer_b = get_serializer_for_model(far_end[0], prefix='Nested')
+                far_end = serializer_b(far_end, many=True, context={'request': request}).data
 
-            path.append((x, y, z))
+            path.append((near_end, cable, far_end))
 
         return Response(path)
 
@@ -94,7 +89,7 @@ class PassThroughPortMixin(object):
         Return all CablePaths which traverse a given pass-through port.
         """
         obj = get_object_or_404(self.queryset, pk=pk)
-        cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination')
+        cablepaths = CablePath.objects.filter(_nodes__contains=obj)
         serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
 
         return Response(serializer.data)
@@ -557,7 +552,7 @@ class ModuleViewSet(NetBoxModelViewSet):
 
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsolePort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filtersets.ConsolePortFilterSet
@@ -566,7 +561,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsoleServerPort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -575,7 +570,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerPort.objects.prefetch_related(
-        'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filtersets.PowerPortFilterSet
@@ -584,7 +579,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related(
-        'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags'
+        'device', 'module__module_bay', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filtersets.PowerOutletFilterSet
@@ -593,8 +588,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
-        'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', '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
@@ -603,7 +598,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
@@ -612,7 +607,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
@@ -657,14 +652,18 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
 #
 
 class CableViewSet(NetBoxModelViewSet):
-    metadata_class = ContentTypeMetadata
-    queryset = Cable.objects.prefetch_related(
-        'termination_a', 'termination_b'
-    )
+    queryset = Cable.objects.prefetch_related('terminations__termination')
     serializer_class = serializers.CableSerializer
     filterset_class = filtersets.CableFilterSet
 
 
+class CableTerminationViewSet(NetBoxModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = CableTermination.objects.prefetch_related('cable', 'termination')
+    serializer_class = serializers.CableTerminationSerializer
+    filterset_class = filtersets.CableTerminationFilterSet
+
+
 #
 # Virtual chassis
 #
@@ -698,7 +697,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
 
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerFeed.objects.prefetch_related(
-        'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
+        'power_panel', 'rack', '_path', 'cable__terminations', 'tags'
     )
     serializer_class = serializers.PowerFeedSerializer
     filterset_class = filtersets.PowerFeedFilterSet
@@ -758,13 +757,13 @@ class ConnectedDeviceViewSet(ViewSet):
             device=peer_device,
             name=peer_interface_name
         )
-        endpoint = peer_interface.connected_endpoint
+        endpoints = peer_interface.connected_endpoints
 
         # If an Interface, return the parent device
-        if type(endpoint) is Interface:
+        if endpoints and type(endpoints[0]) is Interface:
             device = get_object_or_404(
                 Device.objects.restrict(request.user, 'view'),
-                pk=endpoint.device_id
+                pk=endpoints[0].device_id
             )
             return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
 

+ 16 - 0
netbox/dcim/choices.py

@@ -1282,6 +1282,22 @@ class CableLengthUnitChoices(ChoiceSet):
     )
 
 
+#
+# CableTerminations
+#
+
+class CableEndChoices(ChoiceSet):
+
+    SIDE_A = 'A'
+    SIDE_B = 'B'
+
+    CHOICES = (
+        (SIDE_A, 'A'),
+        (SIDE_B, 'B'),
+        # ('', ''),
+    )
+
+
 #
 # PowerFeeds
 #

+ 2 - 0
netbox/dcim/constants.py

@@ -85,6 +85,8 @@ MODULAR_COMPONENT_MODELS = Q(
 # Cabling and connections
 #
 
+CABLE_TRACE_SVG_DEFAULT_WIDTH = 400
+
 # Cable endpoint types
 CABLE_TERMINATION_MODELS = Q(
     Q(app_label='circuits', model__in=(

+ 62 - 38
netbox/dcim/filtersets.py

@@ -21,6 +21,7 @@ from .models import *
 
 __all__ = (
     'CableFilterSet',
+    'CabledObjectFilterSet',
     'CableTerminationFilterSet',
     'ConsoleConnectionFilterSet',
     'ConsolePortFilterSet',
@@ -1117,7 +1118,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
     )
 
 
-class CableTerminationFilterSet(django_filters.FilterSet):
+class CabledObjectFilterSet(django_filters.FilterSet):
     cabled = django_filters.BooleanFilter(
         field_name='cable',
         lookup_expr='isnull',
@@ -1140,7 +1141,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
 class ConsolePortFilterSet(
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
@@ -1150,13 +1151,13 @@ class ConsolePortFilterSet(
 
     class Meta:
         model = ConsolePort
-        fields = ['id', 'name', 'label', 'description']
+        fields = ['id', 'name', 'label', 'description', 'cable_end']
 
 
 class ConsoleServerPortFilterSet(
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
@@ -1166,13 +1167,13 @@ class ConsoleServerPortFilterSet(
 
     class Meta:
         model = ConsoleServerPort
-        fields = ['id', 'name', 'label', 'description']
+        fields = ['id', 'name', 'label', 'description', 'cable_end']
 
 
 class PowerPortFilterSet(
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
@@ -1182,13 +1183,13 @@ 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(
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
@@ -1202,13 +1203,13 @@ class PowerOutletFilterSet(
 
     class Meta:
         model = PowerOutlet
-        fields = ['id', 'name', 'label', 'feed_leg', 'description']
+        fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
 
 
 class InterfaceFilterSet(
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
 ):
     # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
@@ -1288,7 +1289,7 @@ class InterfaceFilterSet(
         model = Interface
         fields = [
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
-            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
+            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
         ]
 
     def filter_device(self, queryset, name, value):
@@ -1342,7 +1343,7 @@ class InterfaceFilterSet(
 class FrontPortFilterSet(
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet
+    CabledObjectFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
@@ -1351,13 +1352,13 @@ class FrontPortFilterSet(
 
     class Meta:
         model = FrontPort
-        fields = ['id', 'name', 'label', 'type', 'color', 'description']
+        fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
 
 
 class RearPortFilterSet(
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet
+    CabledObjectFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
@@ -1366,7 +1367,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):
@@ -1514,10 +1515,18 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
 
 
 class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
-    termination_a_type = ContentTypeFilter()
-    termination_a_id = MultiValueNumberFilter()
-    termination_b_type = ContentTypeFilter()
-    termination_b_id = MultiValueNumberFilter()
+    termination_a_type = ContentTypeFilter(
+        field_name='terminations__termination_type'
+    )
+    termination_a_id = MultiValueNumberFilter(
+        field_name='terminations__termination_id'
+    )
+    termination_b_type = ContentTypeFilter(
+        field_name='terminations__termination_type'
+    )
+    termination_b_id = MultiValueNumberFilter(
+        field_name='terminations__termination_id'
+    )
     type = django_filters.MultipleChoiceFilter(
         choices=CableTypeChoices
     )
@@ -1528,44 +1537,57 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         choices=ColorChoices
     )
     device_id = MultiValueNumberFilter(
-        method='filter_device'
+        method='filter_by_termination'
     )
     device = MultiValueCharFilter(
-        method='filter_device',
+        method='filter_by_termination',
         field_name='device__name'
     )
     rack_id = MultiValueNumberFilter(
-        method='filter_device',
-        field_name='device__rack_id'
+        method='filter_by_termination',
+        field_name='rack_id'
     )
     rack = MultiValueCharFilter(
-        method='filter_device',
-        field_name='device__rack__name'
+        method='filter_by_termination',
+        field_name='rack__name'
+    )
+    location_id = MultiValueNumberFilter(
+        method='filter_by_termination',
+        field_name='location_id'
+    )
+    location = MultiValueCharFilter(
+        method='filter_by_termination',
+        field_name='location__name'
     )
     site_id = MultiValueNumberFilter(
-        method='filter_device',
-        field_name='device__site_id'
+        method='filter_by_termination',
+        field_name='site_id'
     )
     site = MultiValueCharFilter(
-        method='filter_device',
-        field_name='device__site__slug'
+        method='filter_by_termination',
+        field_name='site__slug'
     )
 
     class Meta:
         model = Cable
-        fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
+        fields = ['id', 'label', 'length', 'length_unit']
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(label__icontains=value)
 
-    def filter_device(self, queryset, name, value):
-        queryset = queryset.filter(
-            Q(**{'_termination_a_{}__in'.format(name): value}) |
-            Q(**{'_termination_b_{}__in'.format(name): value})
-        )
-        return queryset
+    def filter_by_termination(self, queryset, name, value):
+        # Filter by a related object cached on CableTermination. Note the underscore preceding the field name.
+        # Supported objects: device, rack, location, site
+        return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
+
+
+class CableTerminationFilterSet(BaseFilterSet):
+
+    class Meta:
+        model = CableTermination
+        fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id']
 
 
 class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@@ -1625,7 +1647,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
         return queryset.filter(qs_filter)
 
 
-class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
+class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         field_name='power_panel__site__region',
@@ -1679,7 +1701,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEn
 
     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():

+ 169 - 277
netbox/dcim/forms/connections.py

@@ -1,279 +1,171 @@
+from django import forms
+
 from circuits.models import Circuit, CircuitTermination, Provider
 from dcim.models import *
-from extras.models import Tag
-from netbox.forms import NetBoxModelForm
-from tenancy.forms import TenancyForm
-from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
-
-__all__ = (
-    'ConnectCableToCircuitTerminationForm',
-    'ConnectCableToConsolePortForm',
-    'ConnectCableToConsoleServerPortForm',
-    'ConnectCableToFrontPortForm',
-    'ConnectCableToInterfaceForm',
-    'ConnectCableToPowerFeedForm',
-    'ConnectCableToPowerPortForm',
-    'ConnectCableToPowerOutletForm',
-    'ConnectCableToRearPortForm',
-)
-
-
-class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm):
-    """
-    Base form for connecting a Cable to a Device component
-    """
-    termination_b_region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        label='Region',
-        required=False
-    )
-    termination_b_sitegroup = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        label='Site group',
-        required=False
-    )
-    termination_b_site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        query_params={
-            'region_id': '$termination_b_region',
-            'group_id': '$termination_b_sitegroup',
-        }
-    )
-    termination_b_location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        label='Location',
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$termination_b_site'
-        }
-    )
-    termination_b_rack = DynamicModelChoiceField(
-        queryset=Rack.objects.all(),
-        label='Rack',
-        required=False,
-        null_option='None',
-        query_params={
-            'site_id': '$termination_b_site',
-            'location_id': '$termination_b_location',
-        }
-    )
-    termination_b_device = DynamicModelChoiceField(
-        queryset=Device.objects.all(),
-        label='Device',
-        required=False,
-        query_params={
-            'site_id': '$termination_b_site',
-            'location_id': '$termination_b_location',
-            'rack_id': '$termination_b_rack',
-        }
-    )
-
-    class Meta:
-        model = Cable
-        fields = [
-            'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack',
-            'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
-            'length', 'length_unit', 'tags',
-        ]
-        widgets = {
-            'status': StaticSelect,
-            'type': StaticSelect,
-            'length_unit': StaticSelect,
-        }
-
-    def clean_termination_b_id(self):
-        # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
-
-
-class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=ConsolePort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=ConsoleServerPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=Interface.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device',
-            'kind': 'physical',
-        }
-    )
-
-
-class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=FrontPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
-    termination_b_id = DynamicModelChoiceField(
-        queryset=RearPort.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'device_id': '$termination_b_device'
-        }
-    )
-
-
-class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm):
-    termination_b_provider = DynamicModelChoiceField(
-        queryset=Provider.objects.all(),
-        label='Provider',
-        required=False
-    )
-    termination_b_region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        label='Region',
-        required=False
-    )
-    termination_b_sitegroup = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        label='Site group',
-        required=False
-    )
-    termination_b_site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        query_params={
-            'region_id': '$termination_b_region',
-            'group_id': '$termination_b_sitegroup',
-        }
-    )
-    termination_b_circuit = DynamicModelChoiceField(
-        queryset=Circuit.objects.all(),
-        label='Circuit',
-        query_params={
-            'provider_id': '$termination_b_provider',
-            'site_id': '$termination_b_site',
-        }
-    )
-    termination_b_id = DynamicModelChoiceField(
-        queryset=CircuitTermination.objects.all(),
-        label='Side',
-        disabled_indicator='_occupied',
-        query_params={
-            'circuit_id': '$termination_b_circuit'
-        }
-    )
-
-    class Meta(ConnectCableToDeviceForm.Meta):
-        fields = [
-            'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site',
-            'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
-            'length', 'length_unit', 'tags',
-        ]
-
-    def clean_termination_b_id(self):
-        # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
-
-
-class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm):
-    termination_b_region = DynamicModelChoiceField(
-        queryset=Region.objects.all(),
-        label='Region',
-        required=False
-    )
-    termination_b_sitegroup = DynamicModelChoiceField(
-        queryset=SiteGroup.objects.all(),
-        label='Site group',
-        required=False
-    )
-    termination_b_site = DynamicModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        query_params={
-            'region_id': '$termination_b_region',
-            'group_id': '$termination_b_sitegroup',
-        }
-    )
-    termination_b_location = DynamicModelChoiceField(
-        queryset=Location.objects.all(),
-        label='Location',
-        required=False,
-        query_params={
-            'site_id': '$termination_b_site'
-        }
-    )
-    termination_b_powerpanel = DynamicModelChoiceField(
-        queryset=PowerPanel.objects.all(),
-        label='Power Panel',
-        required=False,
-        query_params={
-            'site_id': '$termination_b_site',
-            'location_id': '$termination_b_location',
-        }
-    )
-    termination_b_id = DynamicModelChoiceField(
-        queryset=PowerFeed.objects.all(),
-        label='Name',
-        disabled_indicator='_occupied',
-        query_params={
-            'power_panel_id': '$termination_b_powerpanel'
-        }
-    )
-
-    class Meta(ConnectCableToDeviceForm.Meta):
-        fields = [
-            'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location',
-            'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label',
-            'color', 'length', 'length_unit', 'tags',
-        ]
-
-    def clean_termination_b_id(self):
-        # Return the PK rather than the object
-        return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
+from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from .models import CableForm
+
+
+def get_cable_form(a_type, b_type):
+
+    class FormMetaclass(forms.models.ModelFormMetaclass):
+
+        def __new__(mcs, name, bases, attrs):
+
+            for cable_end, term_cls in (('a', a_type), ('b', b_type)):
+
+                attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
+                    queryset=Region.objects.all(),
+                    label='Region',
+                    required=False,
+                    initial_params={
+                        'sites': f'$termination_{cable_end}_site'
+                    }
+                )
+                attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField(
+                    queryset=SiteGroup.objects.all(),
+                    label='Site group',
+                    required=False,
+                    initial_params={
+                        'sites': f'$termination_{cable_end}_site'
+                    }
+                )
+                attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField(
+                    queryset=Site.objects.all(),
+                    label='Site',
+                    required=False,
+                    query_params={
+                        'region_id': f'$termination_{cable_end}_region',
+                        'group_id': f'$termination_{cable_end}_sitegroup',
+                    }
+                )
+                attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField(
+                    queryset=Location.objects.all(),
+                    label='Location',
+                    required=False,
+                    null_option='None',
+                    query_params={
+                        'site_id': f'$termination_{cable_end}_site'
+                    }
+                )
+
+                # Device component
+                if hasattr(term_cls, 'device'):
+
+                    attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
+                        queryset=Rack.objects.all(),
+                        label='Rack',
+                        required=False,
+                        null_option='None',
+                        initial_params={
+                            'devices': f'$termination_{cable_end}_device'
+                        },
+                        query_params={
+                            'site_id': f'$termination_{cable_end}_site',
+                            'location_id': f'$termination_{cable_end}_location',
+                        }
+                    )
+                    attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
+                        queryset=Device.objects.all(),
+                        label='Device',
+                        required=False,
+                        initial_params={
+                            f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations'
+                        },
+                        query_params={
+                            'site_id': f'$termination_{cable_end}_site',
+                            'location_id': f'$termination_{cable_end}_location',
+                            'rack_id': f'$termination_{cable_end}_rack',
+                        }
+                    )
+                    attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
+                        queryset=term_cls.objects.all(),
+                        label=term_cls._meta.verbose_name.title(),
+                        disabled_indicator='_occupied',
+                        query_params={
+                            'device_id': f'$termination_{cable_end}_device',
+                        }
+                    )
+
+                # PowerFeed
+                elif term_cls == PowerFeed:
+
+                    attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
+                        queryset=PowerPanel.objects.all(),
+                        label='Power Panel',
+                        required=False,
+                        initial_params={
+                            'powerfeeds__in': f'${cable_end}_terminations'
+                        },
+                        query_params={
+                            'site_id': f'$termination_{cable_end}_site',
+                            'location_id': f'$termination_{cable_end}_location',
+                        }
+                    )
+                    attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
+                        queryset=term_cls.objects.all(),
+                        label='Power Feed',
+                        disabled_indicator='_occupied',
+                        query_params={
+                            'powerpanel_id': f'$termination_{cable_end}_powerpanel',
+                        }
+                    )
+
+                # CircuitTermination
+                elif term_cls == CircuitTermination:
+
+                    attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
+                        queryset=Provider.objects.all(),
+                        label='Provider',
+                        initial_params={
+                            'circuits': f'$termination_{cable_end}_circuit'
+                        },
+                        required=False
+                    )
+                    attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
+                        queryset=Circuit.objects.all(),
+                        label='Circuit',
+                        initial_params={
+                            'terminations__in': f'${cable_end}_terminations'
+                        },
+                        query_params={
+                            'provider_id': f'$termination_{cable_end}_provider',
+                            'site_id': f'$termination_{cable_end}_site',
+                        }
+                    )
+                    attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
+                        queryset=term_cls.objects.all(),
+                        label='Side',
+                        disabled_indicator='_occupied',
+                        query_params={
+                            'circuit_id': f'termination_{cable_end}_circuit',
+                        }
+                    )
+
+            return super().__new__(mcs, name, bases, attrs)
+
+    class _CableForm(CableForm, metaclass=FormMetaclass):
+
+        def __init__(self, *args, **kwargs):
+
+            # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
+            for field_name in ('a_terminations', 'b_terminations'):
+                if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
+                    kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
+
+            super().__init__(*args, **kwargs)
+
+            if self.instance and self.instance.pk:
+                # Initialize A/B terminations when modifying an existing Cable instance
+                self.initial['a_terminations'] = self.instance.get_a_terminations()
+                self.initial['b_terminations'] = self.instance.get_b_terminations()
+
+        def save(self, *args, **kwargs):
+
+            # Set the A/B terminations on the Cable instance
+            self.instance.a_terminations = self.cleaned_data['a_terminations']
+            self.instance.b_terminations = self.cleaned_data['b_terminations']
+
+            return super().save(*args, **kwargs)
+
+    return _CableForm

+ 14 - 3
netbox/dcim/forms/filtersets.py

@@ -730,7 +730,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Cable
     fieldsets = (
         (None, ('q', 'tag')),
-        ('Location', ('site_id', 'rack_id', 'device_id')),
+        ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
         ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -747,13 +747,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         },
         label=_('Site')
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location'),
+        null_option='None',
+        query_params={
+            'site_id': '$site_id'
+        }
+    )
     rack_id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         required=False,
         label=_('Rack'),
         null_option='None',
         query_params={
-            'site_id': '$site_id'
+            'site_id': '$site_id',
+            'location_id': '$location_id',
         }
     )
     device_id = DynamicModelMultipleChoiceField(
@@ -761,8 +771,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         required=False,
         query_params={
             'site_id': '$site_id',
-            'tenant_id': '$tenant_id',
+            'location_id': '$location_id',
             'rack_id': '$rack_id',
+            'tenant_id': '$tenant_id',
         },
         label=_('Device')
     )

+ 5 - 0
netbox/dcim/graphql/mixins.py

@@ -0,0 +1,5 @@
+class CabledObjectMixin:
+
+    def resolve_cable_end(self, info):
+        # Handle empty values
+        return self.cable_end or None

+ 17 - 8
netbox/dcim/graphql/types.py

@@ -7,6 +7,7 @@ from extras.graphql.mixins import (
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
+from .mixins import CabledObjectMixin
 
 __all__ = (
     'CableType',
@@ -99,7 +100,15 @@ class CableType(NetBoxObjectType):
         return self.length_unit or None
 
 
-class ConsolePortType(ComponentObjectType):
+class CableTerminationType(NetBoxObjectType):
+
+    class Meta:
+        model = models.CableTermination
+        fields = '__all__'
+        filterset_class = filtersets.CableTerminationFilterSet
+
+
+class ConsolePortType(ComponentObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.ConsolePort
@@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
         return self.type or None
 
 
-class ConsoleServerPortType(ComponentObjectType):
+class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.ConsoleServerPort
@@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType):
         return self.airflow or None
 
 
-class FrontPortType(ComponentObjectType):
+class FrontPortType(ComponentObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.FrontPort
@@ -219,7 +228,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
-class InterfaceType(IPAddressesMixin, ComponentObjectType):
+class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.Interface
@@ -322,7 +331,7 @@ class PlatformType(OrganizationalObjectType):
         filterset_class = filtersets.PlatformFilterSet
 
 
-class PowerFeedType(NetBoxObjectType):
+class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.PowerFeed
@@ -330,7 +339,7 @@ class PowerFeedType(NetBoxObjectType):
         filterset_class = filtersets.PowerFeedFilterSet
 
 
-class PowerOutletType(ComponentObjectType):
+class PowerOutletType(ComponentObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.PowerOutlet
@@ -366,7 +375,7 @@ class PowerPanelType(NetBoxObjectType):
         filterset_class = filtersets.PowerPanelFilterSet
 
 
-class PowerPortType(ComponentObjectType):
+class PowerPortType(ComponentObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.PowerPort
@@ -418,7 +427,7 @@ class RackRoleType(OrganizationalObjectType):
         filterset_class = filtersets.RackRoleFilterSet
 
 
-class RearPortType(ComponentObjectType):
+class RearPortType(ComponentObjectType, CabledObjectMixin):
 
     class Meta:
         model = models.RearPort

+ 1 - 1
netbox/dcim/management/commands/trace_paths.py

@@ -81,7 +81,7 @@ class Command(BaseCommand):
             self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
             i = 0
             for i, obj in enumerate(origins, start=1):
-                create_cablepath(obj)
+                create_cablepath([obj])
                 if not i % 100:
                     self.draw_progress_bar(i * 100 / origins_count)
             self.draw_progress_bar(100)

+ 95 - 0
netbox/dcim/migrations/0157_new_cabling_models.py

@@ -0,0 +1,95 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0156_location_status'),
+    ]
+
+    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')),
+                ('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')),
+                ('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')),
+                ('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')),
+                ('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')),
+            ],
+            options={
+                'ordering': ('cable', 'cable_end', 'pk'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='cabletermination',
+            constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_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),
+        ),
+    ]

+ 76 - 0
netbox/dcim/migrations/0158_populate_cable_terminations.py

@@ -0,0 +1,76 @@
+from django.db import migrations
+
+
+def cache_related_objects(termination):
+    """
+    Replicate caching logic from CableTermination.cache_related_objects()
+    """
+    attrs = {}
+
+    # Device components
+    if getattr(termination, 'device', None):
+        attrs['_device'] = termination.device
+        attrs['_rack'] = termination.device.rack
+        attrs['_location'] = termination.device.location
+        attrs['_site'] = termination.device.site
+
+    # Power feeds
+    elif getattr(termination, 'rack', None):
+        attrs['_rack'] = termination.rack
+        attrs['_location'] = termination.rack.location
+        attrs['_site'] = termination.rack.site
+
+    # Circuit terminations
+    elif getattr(termination, 'site', None):
+        attrs['_site'] = termination.site
+
+    return attrs
+
+
+def populate_cable_terminations(apps, schema_editor):
+    """
+    Replicate terminations from the Cable model into CableTermination instances.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Cable = apps.get_model('dcim', 'Cable')
+    CableTermination = apps.get_model('dcim', 'CableTermination')
+
+    # Retrieve the necessary data from Cable objects
+    cables = Cable.objects.values(
+        'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id'
+    )
+
+    # Queue CableTerminations to be created
+    cable_terminations = []
+    for i, cable in enumerate(cables, start=1):
+        for cable_end in ('a', 'b'):
+            # We must manually instantiate the termination object, because GFK fields are not
+            # supported within migrations.
+            termination_ct = ContentType.objects.get(pk=cable[f'termination_{cable_end}_type'])
+            termination_model = apps.get_model(termination_ct.app_label, termination_ct.model)
+            termination = termination_model.objects.get(pk=cable[f'termination_{cable_end}_id'])
+
+            cable_terminations.append(CableTermination(
+                cable_id=cable['id'],
+                cable_end=cable_end.upper(),
+                termination_type_id=cable[f'termination_{cable_end}_type'],
+                termination_id=cable[f'termination_{cable_end}_id'],
+                **cache_related_objects(termination)
+            ))
+
+    # Bulk create the termination objects
+    CableTermination.objects.bulk_create(cable_terminations, batch_size=100)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0157_new_cabling_models'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=populate_cable_terminations,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 50 - 0
netbox/dcim/migrations/0159_populate_cable_paths.py

@@ -0,0 +1,50 @@
+from django.db import migrations
+
+from dcim.utils import compile_path_node
+
+
+def populate_cable_paths(apps, schema_editor):
+    """
+    Replicate terminations from the Cable model into CableTermination instances.
+    """
+    CablePath = apps.get_model('dcim', 'CablePath')
+
+    # Construct the new two-dimensional path, and add the origin & destination objects to the nodes list
+    cable_paths = []
+    for cablepath in CablePath.objects.all():
+
+        # Origin
+        origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id)
+        cablepath.path.append([origin])
+        cablepath._nodes.insert(0, origin)
+
+        # Transit nodes
+        cablepath.path.extend([
+            [node] for node in cablepath._nodes[1:]
+        ])
+
+        # Destination
+        if cablepath.destination_id:
+            destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id)
+            cablepath.path.append([destination])
+            cablepath._nodes.append(destination)
+            cablepath.is_complete = True
+
+        cable_paths.append(cablepath)
+
+    # Bulk update all CableTerminations
+    CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0158_populate_cable_terminations'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=populate_cable_paths,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 42 - 0
netbox/dcim/migrations/0160_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', '0037_new_cabling_models'),
+        ('dcim', '0159_populate_cable_paths'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=populate_cable_terminations,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 134 - 0
netbox/dcim/migrations/0161_cabling_cleanup.py

@@ -0,0 +1,134 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0160_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',
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='_termination_a_device',
+        ),
+        migrations.RemoveField(
+            model_name='cable',
+            name='_termination_b_device',
+        ),
+
+        # 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',
+        ),
+
+    ]

+ 423 - 297
netbox/dcim/models/cables.py

@@ -1,10 +1,12 @@
+import itertools
 from collections import defaultdict
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import Sum
+from django.dispatch import Signal
 from django.urls import reverse
 
 from dcim.choices import *
@@ -13,17 +15,21 @@ from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
 from netbox.models import NetBoxModel
 from utilities.fields import ColorField
+from utilities.querysets import RestrictedQuerySet
 from utilities.utils import to_meters
-from .devices import Device
+from wireless.models import WirelessLink
 from .device_components import FrontPort, RearPort
 
-
 __all__ = (
     'Cable',
     'CablePath',
+    'CableTermination',
 )
 
 
+trace_paths = Signal()
+
+
 #
 # Cables
 #
@@ -32,28 +38,6 @@ class Cable(NetBoxModel):
     """
     A physical connection between two endpoints.
     """
-    termination_a_type = models.ForeignKey(
-        to=ContentType,
-        limit_choices_to=CABLE_TERMINATION_MODELS,
-        on_delete=models.PROTECT,
-        related_name='+'
-    )
-    termination_a_id = models.PositiveBigIntegerField()
-    termination_a = GenericForeignKey(
-        ct_field='termination_a_type',
-        fk_field='termination_a_id'
-    )
-    termination_b_type = models.ForeignKey(
-        to=ContentType,
-        limit_choices_to=CABLE_TERMINATION_MODELS,
-        on_delete=models.PROTECT,
-        related_name='+'
-    )
-    termination_b_id = models.PositiveBigIntegerField()
-    termination_b = GenericForeignKey(
-        ct_field='termination_b_type',
-        fk_field='termination_b_id'
-    )
     type = models.CharField(
         max_length=50,
         choices=CableTypeChoices,
@@ -96,31 +80,11 @@ class Cable(NetBoxModel):
         blank=True,
         null=True
     )
-    # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
-    # their associated Devices.
-    _termination_a_device = models.ForeignKey(
-        to=Device,
-        on_delete=models.CASCADE,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    _termination_b_device = models.ForeignKey(
-        to=Device,
-        on_delete=models.CASCADE,
-        related_name='+',
-        blank=True,
-        null=True
-    )
 
     class Meta:
-        ordering = ['pk']
-        unique_together = (
-            ('termination_a_type', 'termination_a_id'),
-            ('termination_b_type', 'termination_b_id'),
-        )
+        ordering = ('pk',)
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
         super().__init__(*args, **kwargs)
 
         # A copy of the PK to be used by __str__ in case the object is deleted
@@ -129,19 +93,12 @@ class Cable(NetBoxModel):
         # Cache the original status so we can check later if it's been changed
         self._orig_status = self.status
 
-    @classmethod
-    def from_db(cls, db, field_names, values):
-        """
-        Cache the original A and B terminations of existing Cable instances for later reference inside clean().
-        """
-        instance = super().from_db(db, field_names, values)
-
-        instance._orig_termination_a_type_id = instance.termination_a_type_id
-        instance._orig_termination_a_id = instance.termination_a_id
-        instance._orig_termination_b_type_id = instance.termination_b_type_id
-        instance._orig_termination_b_id = instance.termination_b_id
-
-        return instance
+        # Assign any *new* CableTerminations for the instance. These will replace any existing
+        # terminations on save().
+        if a_terminations is not None:
+            self.a_terminations = a_terminations
+        if b_terminations is not None:
+            self.b_terminations = b_terminations
 
     def __str__(self):
         pk = self.pk or self._pk
@@ -151,123 +108,41 @@ class Cable(NetBoxModel):
         return reverse('dcim:cable', args=[self.pk])
 
     def clean(self):
-        from circuits.models import CircuitTermination
-
         super().clean()
 
-        # Validate that termination A exists
-        if not hasattr(self, 'termination_a_type'):
-            raise ValidationError('Termination A type has not been specified')
-        try:
-            self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
-        except ObjectDoesNotExist:
-            raise ValidationError({
-                'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
-            })
-
-        # Validate that termination B exists
-        if not hasattr(self, 'termination_b_type'):
-            raise ValidationError('Termination B type has not been specified')
-        try:
-            self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
-        except ObjectDoesNotExist:
-            raise ValidationError({
-                'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
-            })
-
-        # If editing an existing Cable instance, check that neither termination has been modified.
-        if self.pk:
-            err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
-            if (
-                self.termination_a_type_id != self._orig_termination_a_type_id or
-                self.termination_a_id != self._orig_termination_a_id
-            ):
-                raise ValidationError({
-                    'termination_a': err_msg
-                })
-            if (
-                self.termination_b_type_id != self._orig_termination_b_type_id or
-                self.termination_b_id != self._orig_termination_b_id
-            ):
-                raise ValidationError({
-                    'termination_b': err_msg
-                })
-
-        type_a = self.termination_a_type.model
-        type_b = self.termination_b_type.model
-
-        # Validate interface types
-        if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
-                    self.termination_a.get_type_display()
-                )
-            })
-        if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
-                    self.termination_b.get_type_display()
-                )
-            })
-
-        # Check that termination types are compatible
-        if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
-            raise ValidationError(
-                f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
-            )
-
-        # Check that two connected RearPorts have the same number of positions (if both are >1)
-        if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
-            if self.termination_a.positions > 1 and self.termination_b.positions > 1:
-                if self.termination_a.positions != self.termination_b.positions:
-                    raise ValidationError(
-                        f"{self.termination_a} has {self.termination_a.positions} position(s) but "
-                        f"{self.termination_b} has {self.termination_b.positions}. "
-                        f"Both terminations must have the same number of positions (if greater than one)."
-                    )
-
-        # A termination point cannot be connected to itself
-        if self.termination_a == self.termination_b:
-            raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
-
-        # A front port cannot be connected to its corresponding rear port
-        if (
-            type_a in ['frontport', 'rearport'] and
-            type_b in ['frontport', 'rearport'] and
-            (
-                getattr(self.termination_a, 'rear_port', None) == self.termination_b or
-                getattr(self.termination_b, 'rear_port', None) == self.termination_a
-            )
-        ):
-            raise ValidationError("A front port cannot be connected to it corresponding rear port")
-
-        # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
-        if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None:
-            raise ValidationError({
-                'termination_a_id': "Circuit terminations attached to a provider network may not be cabled."
-            })
-        if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None:
-            raise ValidationError({
-                'termination_b_id': "Circuit terminations attached to a provider network may not be cabled."
-            })
-
-        # Check for an existing Cable connected to either termination object
-        if self.termination_a.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_a, self.termination_a.cable_id
-            ))
-        if self.termination_b.cable not in (None, self):
-            raise ValidationError("{} already has a cable attached (#{})".format(
-                self.termination_b, self.termination_b.cable_id
-            ))
-
         # Validate length and length_unit
         if self.length is not None and not self.length_unit:
             raise ValidationError("Must specify a unit when setting a cable length")
         elif self.length is None:
             self.length_unit = ''
 
+        a_terminations = [
+            CableTermination(cable=self, cable_end='A', termination=t)
+            for t in getattr(self, 'a_terminations', [])
+        ]
+        b_terminations = [
+            CableTermination(cable=self, cable_end='B', termination=t)
+            for t in getattr(self, 'b_terminations', [])
+        ]
+
+        # Check that all termination objects for either end are of the same type
+        for terms in (a_terminations, b_terminations):
+            if len(terms) > 1 and not all(t.termination_type == terms[0].termination_type for t in terms[1:]):
+                raise ValidationError("Cannot connect different termination types to same end of cable.")
+
+        # Check that termination types are compatible
+        if a_terminations and b_terminations:
+            a_type = a_terminations[0].termination_type.model
+            b_type = b_terminations[0].termination_type.model
+            if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
+                raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
+
+        # Run clean() on any new CableTerminations
+        for cabletermination in [*a_terminations, *b_terminations]:
+            cabletermination.clean()
+
     def save(self, *args, **kwargs):
+        _created = self.pk is None
 
         # Store the given length (if any) in meters for use in database ordering
         if self.length and self.length_unit:
@@ -275,199 +150,454 @@ class Cable(NetBoxModel):
         else:
             self._abs_length = None
 
-        # Store the parent Device for the A and B terminations (if applicable) to enable filtering
-        if hasattr(self.termination_a, 'device'):
-            self._termination_a_device = self.termination_a.device
-        if hasattr(self.termination_b, 'device'):
-            self._termination_b_device = self.termination_b.device
-
         super().save(*args, **kwargs)
 
         # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
         self._pk = self.pk
 
+        # Retrieve existing A/B terminations for the Cable
+        a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')}
+        b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')}
+
+        # Delete stale CableTerminations
+        if hasattr(self, 'a_terminations'):
+            for termination, ct in a_terminations.items():
+                if termination not in self.a_terminations:
+                    ct.delete()
+        if hasattr(self, 'b_terminations'):
+            for termination, ct in b_terminations.items():
+                if termination not in self.b_terminations:
+                    ct.delete()
+
+        # Save new CableTerminations (if any)
+        if hasattr(self, 'a_terminations'):
+            for termination in self.a_terminations:
+                if termination not in a_terminations:
+                    CableTermination(cable=self, cable_end='A', termination=termination).save()
+        if hasattr(self, 'b_terminations'):
+            for termination in self.b_terminations:
+                if termination not in b_terminations:
+                    CableTermination(cable=self, cable_end='B', termination=termination).save()
+
+        trace_paths.send(Cable, instance=self, created=_created)
+
     def get_status_color(self):
         return LinkStatusChoices.colors.get(self.status)
 
-    def get_compatible_types(self):
+    def get_a_terminations(self):
+        # Query self.terminations.all() to leverage cached results
+        return [
+            ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
+        ]
+
+    def get_b_terminations(self):
+        # Query self.terminations.all() to leverage cached results
+        return [
+            ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
+        ]
+
+
+class CableTermination(models.Model):
+    """
+    A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination).
+    """
+    cable = models.ForeignKey(
+        to='dcim.Cable',
+        on_delete=models.CASCADE,
+        related_name='terminations'
+    )
+    cable_end = models.CharField(
+        max_length=1,
+        choices=CableEndChoices,
+        verbose_name='End'
+    )
+    termination_type = models.ForeignKey(
+        to=ContentType,
+        limit_choices_to=CABLE_TERMINATION_MODELS,
+        on_delete=models.PROTECT,
+        related_name='+'
+    )
+    termination_id = models.PositiveBigIntegerField()
+    termination = GenericForeignKey(
+        ct_field='termination_type',
+        fk_field='termination_id'
+    )
+
+    # Cached associations to enable efficient filtering
+    _device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        blank=True,
+        null=True
+    )
+    _rack = models.ForeignKey(
+        to='dcim.Rack',
+        on_delete=models.CASCADE,
+        blank=True,
+        null=True
+    )
+    _location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.CASCADE,
+        blank=True,
+        null=True
+    )
+    _site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        blank=True,
+        null=True
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('cable', 'cable_end', 'pk')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('termination_type', 'termination_id'),
+                name='dcim_cable_termination_unique_termination'
+            ),
+        )
+
+    def __str__(self):
+        return f'Cable {self.cable} to {self.termination}'
+
+    def clean(self):
+        super().clean()
+
+        # Validate interface type (if applicable)
+        if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
+            })
+
+        # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
+        if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
+            raise ValidationError({
+                'termination': "Circuit terminations attached to a provider network may not be cabled."
+            })
+
+    def save(self, *args, **kwargs):
+
+        # Cache objects associated with the terminating object (for filtering)
+        self.cache_related_objects()
+
+        super().save(*args, **kwargs)
+
+        # 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,
+            cable_end=self.cable_end
+        )
+
+    def delete(self, *args, **kwargs):
+
+        # Delete the cable association on the terminating object
+        termination_model = self.termination._meta.model
+        termination_model.objects.filter(pk=self.termination_id).update(
+            cable=None,
+            cable_end=''
+        )
+
+        super().delete(*args, **kwargs)
+
+    def cache_related_objects(self):
         """
-        Return all termination types compatible with termination A.
+        Cache objects related to the termination (e.g. device, rack, site) directly on the object to
+        enable efficient filtering.
         """
-        if self.termination_a is None:
-            return
-        return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
+        assert self.termination is not None
+
+        # Device components
+        if getattr(self.termination, 'device', None):
+            self._device = self.termination.device
+            self._rack = self.termination.device.rack
+            self._location = self.termination.device.location
+            self._site = self.termination.device.site
+
+        # Power feeds
+        elif getattr(self.termination, 'rack', None):
+            self._rack = self.termination.rack
+            self._location = self.termination.rack.location
+            self._site = self.termination.rack.site
+
+        # Circuit terminations
+        elif getattr(self.termination, 'site', None):
+            self._site = self.termination.site
 
 
 class CablePath(models.Model):
     """
-    A CablePath instance represents the physical path from an origin to a destination, including all intermediate
-    elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
-    not terminate on a PathEndpoint).
+    A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes,
+    including all intermediate elements.
 
-    `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
-    path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
+    `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can
+    terminate to one or more objects.)  For example, consider the following
     topology:
 
-                     1                              2                              3
-        Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
+                     A                              B                              C
+        Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2
+                        Front Port 2                                 Front Port 4
 
     This path would be expressed as:
 
     CablePath(
-        origin = Interface A
-        destination = Interface B
-        path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
+        path = [
+            [Interface 1],
+            [Cable A],
+            [Front Port 1, Front Port 2],
+            [Rear Port 1],
+            [Cable B],
+            [Rear Port 2],
+            [Front Port 3, Front Port 4],
+            [Cable C],
+            [Interface 2],
+        ]
     )
 
-    `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
-    "connected".
+    `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True
+    if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the
+    path diverges across multiple cables.
+
+    `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
     """
-    origin_type = models.ForeignKey(
-        to=ContentType,
-        on_delete=models.CASCADE,
-        related_name='+'
+    path = models.JSONField(
+        default=list
     )
-    origin_id = models.PositiveBigIntegerField()
-    origin = GenericForeignKey(
-        ct_field='origin_type',
-        fk_field='origin_id'
-    )
-    destination_type = models.ForeignKey(
-        to=ContentType,
-        on_delete=models.CASCADE,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    destination_id = models.PositiveBigIntegerField(
-        blank=True,
-        null=True
-    )
-    destination = GenericForeignKey(
-        ct_field='destination_type',
-        fk_field='destination_id'
-    )
-    path = PathField()
     is_active = models.BooleanField(
         default=False
     )
+    is_complete = models.BooleanField(
+        default=False
+    )
     is_split = models.BooleanField(
         default=False
     )
-
-    class Meta:
-        unique_together = ('origin_type', 'origin_id')
+    _nodes = PathField()
 
     def __str__(self):
-        status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
-        return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
+        return f"Path #{self.pk}: {len(self.path)} hops"
 
     def save(self, *args, **kwargs):
+
+        # Save the flattened nodes list
+        self._nodes = list(itertools.chain(*self.path))
+
         super().save(*args, **kwargs)
 
-        # Record a direct reference to this CablePath on its originating object
-        model = self.origin._meta.model
-        model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
+        # Record a direct reference to this CablePath on its originating object(s)
+        origin_model = self.origin_type.model_class()
+        origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
+        origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
+
+    @property
+    def origin_type(self):
+        if self.path:
+            ct_id, _ = decompile_path_node(self.path[0][0])
+            return ContentType.objects.get_for_id(ct_id)
+
+    @property
+    def destination_type(self):
+        if self.is_complete:
+            ct_id, _ = decompile_path_node(self.path[-1][0])
+            return ContentType.objects.get_for_id(ct_id)
+
+    @property
+    def path_objects(self):
+        """
+        Cache and return the complete path as lists of objects, derived from their annotation within the path.
+        """
+        if not hasattr(self, '_path_objects'):
+            self._path_objects = self._get_path()
+        return self._path_objects
+
+    @property
+    def origins(self):
+        """
+        Return the list of originating objects.
+        """
+        if hasattr(self, '_path_objects'):
+            return self.path_objects[0]
+        return [
+            path_node_to_object(node) for node in self.path[0]
+        ]
+
+    @property
+    def destinations(self):
+        """
+        Return the list of destination objects, if the path is complete.
+        """
+        if not self.is_complete:
+            return []
+        if hasattr(self, '_path_objects'):
+            return self.path_objects[-1]
+        return [
+            path_node_to_object(node) for node in self.path[-1]
+        ]
 
     @property
     def segment_count(self):
-        total_length = 1 + len(self.path) + (1 if self.destination else 0)
-        return int(total_length / 3)
+        return int(len(self.path) / 3)
 
     @classmethod
-    def from_origin(cls, origin):
+    def from_origin(cls, terminations):
         """
-        Create a new CablePath instance as traced from the given path origin.
+        Create a new CablePath instance as traced from the given termination objects. These can be any object to which a
+        Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be
+        of the same type and must belong to the same parent object.
         """
         from circuits.models import CircuitTermination
 
-        if origin is None or origin.link is None:
-            return None
-
-        destination = None
         path = []
         position_stack = []
+        is_complete = False
         is_active = True
         is_split = False
 
-        node = origin
-        while node.link is not None:
-            if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
+        while terminations:
+
+            # Terminations must all be of the same type
+            assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
+
+            # Step 1: Record the near-end termination object(s)
+            path.append([
+                object_to_path_node(t) for t in terminations
+            ])
+
+            # Step 2: Determine the attached link (Cable or WirelessLink), if any
+            link = terminations[0].link
+            assert all(t.link == link for t in terminations[1:])
+            if link is None and len(path) == 1:
+                # If this is the start of the path and no link exists, return None
+                return None
+            elif link is None:
+                # Otherwise, halt the trace if no link exists
+                break
+            assert type(link) in (Cable, WirelessLink)
+
+            # Step 3: Record the link and update path status if not "connected"
+            path.append([object_to_path_node(link)])
+            if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
                 is_active = False
 
-            # Follow the link to its far-end termination
-            path.append(object_to_path_node(node.link))
-            peer_termination = node.get_link_peer()
-
-            # Follow a FrontPort to its corresponding RearPort
-            if isinstance(peer_termination, FrontPort):
-                path.append(object_to_path_node(peer_termination))
-                node = peer_termination.rear_port
-                if node.positions > 1:
-                    position_stack.append(peer_termination.rear_port_position)
-                path.append(object_to_path_node(node))
-
-            # Follow a RearPort to its corresponding FrontPort (if any)
-            elif isinstance(peer_termination, RearPort):
-                path.append(object_to_path_node(peer_termination))
-
-                # Determine the peer FrontPort's position
-                if peer_termination.positions == 1:
-                    position = 1
+            # Step 4: Determine the far-end terminations
+            if isinstance(link, Cable):
+                termination_type = ContentType.objects.get_for_model(terminations[0])
+                local_cable_terminations = CableTermination.objects.filter(
+                    termination_type=termination_type,
+                    termination_id__in=[t.pk for t in terminations]
+                )
+                # Terminations must all belong to same end of Cable
+                local_cable_end = local_cable_terminations[0].cable_end
+                assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
+                remote_cable_terminations = CableTermination.objects.filter(
+                    cable=link,
+                    cable_end='A' if local_cable_end == 'B' else 'B'
+                )
+                remote_terminations = [ct.termination for ct in remote_cable_terminations]
+            else:
+                # WirelessLink
+                remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
+
+            # Step 5: Record the far-end termination object(s)
+            path.append([
+                object_to_path_node(t) for t in remote_terminations
+            ])
+
+            # Step 6: Determine the "next hop" terminations, if applicable
+            if isinstance(remote_terminations[0], FrontPort):
+                # Follow FrontPorts to their corresponding RearPorts
+                rear_ports = RearPort.objects.filter(
+                    pk__in=[t.rear_port_id for t in remote_terminations]
+                )
+                if len(rear_ports) > 1:
+                    assert all(rp.positions == 1 for rp in rear_ports)
+                elif rear_ports[0].positions > 1:
+                    position_stack.append([fp.rear_port_position for fp in remote_terminations])
+
+                terminations = rear_ports
+
+            elif isinstance(remote_terminations[0], RearPort):
+
+                if len(remote_terminations) > 1 or 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
+                    )
                 elif position_stack:
-                    position = position_stack.pop()
+                    front_ports = FrontPort.objects.filter(
+                        rear_port_id=remote_terminations[0].pk,
+                        rear_port_position__in=position_stack.pop()
+                    )
                 else:
-                    # No position indicated: path has split, so we stop at the RearPort
+                    # No position indicated: path has split, so we stop at the RearPorts
                     is_split = True
                     break
 
-                try:
-                    node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
-                    path.append(object_to_path_node(node))
-                except ObjectDoesNotExist:
-                    # No corresponding FrontPort found for the RearPort
+                terminations = front_ports
+
+            elif isinstance(remote_terminations[0], CircuitTermination):
+                # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
+                term_side = remote_terminations[0].term_side
+                assert all(ct.term_side == term_side for ct in remote_terminations[1:])
+                circuit_termination = CircuitTermination.objects.filter(
+                    circuit=remote_terminations[0].circuit,
+                    term_side='Z' if term_side == 'A' else 'A'
+                ).first()
+                if circuit_termination is None:
                     break
-
-            # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
-            elif isinstance(peer_termination, CircuitTermination):
-                path.append(object_to_path_node(peer_termination))
-                # Get peer CircuitTermination
-                node = peer_termination.get_peer_termination()
-                if node:
-                    path.append(object_to_path_node(node))
-                    if node.provider_network:
-                        destination = node.provider_network
-                        break
-                    elif node.site and not node.cable:
-                        destination = node.site
-                        break
-                else:
-                    # No peer CircuitTermination exists; halt the trace
+                elif circuit_termination.provider_network:
+                    # Circuit terminates to a ProviderNetwork
+                    path.extend([
+                        [object_to_path_node(circuit_termination)],
+                        [object_to_path_node(circuit_termination.provider_network)],
+                    ])
+                    break
+                elif circuit_termination.site and not circuit_termination.cable:
+                    # Circuit terminates to a Site
+                    path.extend([
+                        [object_to_path_node(circuit_termination)],
+                        [object_to_path_node(circuit_termination.site)],
+                    ])
                     break
 
+                terminations = [circuit_termination]
+
             # Anything else marks the end of the path
             else:
-                destination = peer_termination
+                is_complete = True
                 break
 
-        if destination is None:
-            is_active = False
-
         return cls(
-            origin=origin,
-            destination=destination,
             path=path,
+            is_complete=is_complete,
             is_active=is_active,
             is_split=is_split
         )
 
-    def get_path(self):
+    def retrace(self):
+        """
+        Retrace the path from the currently-defined originating termination(s)
+        """
+        _new = self.from_origin(self.origins)
+        if _new:
+            self.path = _new.path
+            self.is_complete = _new.is_complete
+            self.is_active = _new.is_active
+            self.is_split = _new.is_split
+            self.save()
+        else:
+            self.delete()
+
+    def _get_path(self):
         """
         Return the path as a list of prefetched objects.
         """
         # Compile a list of IDs to prefetch for each type of model in the path
         to_prefetch = defaultdict(list)
-        for node in self.path:
+        for node in self._nodes:
             ct_id, object_id = decompile_path_node(node)
             to_prefetch[ct_id].append(object_id)
 
@@ -484,19 +614,15 @@ class CablePath(models.Model):
 
         # Replicate the path using the prefetched objects.
         path = []
-        for node in self.path:
-            ct_id, object_id = decompile_path_node(node)
-            path.append(prefetched[ct_id][object_id])
+        for step in self.path:
+            nodes = []
+            for node in step:
+                ct_id, object_id = decompile_path_node(node)
+                nodes.append(prefetched[ct_id][object_id])
+            path.append(nodes)
 
         return path
 
-    @property
-    def last_node(self):
-        """
-        Return either the destination or the last node within the path.
-        """
-        return self.destination or path_node_to_object(self.path[-1])
-
     def get_cable_ids(self):
         """
         Return all Cable IDs within the path.
@@ -504,7 +630,7 @@ class CablePath(models.Model):
         cable_ct = ContentType.objects.get_for_model(Cable).pk
         cable_ids = []
 
-        for node in self.path:
+        for node in self._nodes:
             ct, id = decompile_path_node(node)
             if ct == cable_ct:
                 cable_ids.append(id)
@@ -527,6 +653,6 @@ class CablePath(models.Model):
         """
         Return all available next segments in a split cable path.
         """
-        rearport = path_node_to_object(self.path[-1])
+        rearport = path_node_to_object(self._nodes[-1])
 
         return FrontPort.objects.filter(rear_port=rearport)

+ 115 - 93
netbox/dcim/models/device_components.py

@@ -1,6 +1,8 @@
+from functools import cached_property
+
 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
@@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import MACAddressField, WWNField
-from dcim.svg import CableTraceSVG
 from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
@@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr
 
 __all__ = (
     'BaseInterface',
-    'LinkTermination',
+    'CabledObjectModel',
     'ConsolePort',
     'ConsoleServerPort',
     'DeviceBay',
@@ -103,14 +104,10 @@ class ModularComponentModel(ComponentModel):
         abstract = True
 
 
-class LinkTermination(models.Model):
+class CabledObjectModel(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. Provides the `cable` and `cable_end`
+    fields for caching cable associations, as well as `mark_connected` to designate "fake" connections.
     """
     cable = models.ForeignKey(
         to='dcim.Cable',
@@ -119,36 +116,21 @@ 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,
         help_text="Treat as if a cable is connected"
     )
 
-    # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
-    _cabled_as_a = GenericRelation(
-        to='dcim.Cable',
-        content_type_field='termination_a_type',
-        object_id_field='termination_a_id'
-    )
-    _cabled_as_b = GenericRelation(
-        to='dcim.Cable',
-        content_type_field='termination_b_type',
-        object_id_field='termination_b_id'
+    cable_terminations = GenericRelation(
+        to='dcim.CableTermination',
+        content_type_field='termination_type',
+        object_id_field='termination_id',
+        related_query_name='%(class)s',
     )
 
     class Meta:
@@ -157,13 +139,32 @@ class LinkTermination(models.Model):
     def clean(self):
         super().clean()
 
-        if self.mark_connected and self.cable_id:
+        if self.cable and not self.cable_end:
+            raise ValidationError({
+                "cable_end": "Must specify cable end (A or B) when attaching a cable."
+            })
+        if self.cable_end and not self.cable:
+            raise ValidationError({
+                "cable_end": "Cable end must not be set without a cable."
+            })
+        if self.mark_connected and self.cable:
             raise ValidationError({
                 "mark_connected": "Cannot mark as connected with a cable attached."
             })
 
-    def get_link_peer(self):
-        return self._link_peer
+    @property
+    def link(self):
+        """
+        Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
+        """
+        return self.cable
+
+    @cached_property
+    def link_peers(self):
+        if self.cable:
+            peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
+            return [peer.termination for peer in peers]
+        return []
 
     @property
     def _occupied(self):
@@ -171,19 +172,18 @@ class LinkTermination(models.Model):
 
     @property
     def parent_object(self):
-        raise NotImplementedError("CableTermination models must implement parent_object()")
+        raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
 
     @property
-    def link(self):
-        """
-        Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
-        """
-        return self.cable
+    def opposite_cable_end(self):
+        if not self.cable_end:
+            return None
+        return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
 
 
 class PathEndpoint(models.Model):
     """
-    An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
+    An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically,
     these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
 
     `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
@@ -206,50 +206,41 @@ class PathEndpoint(models.Model):
         origin = self
         path = []
 
-        # Construct the complete path
+        # Construct the complete path (including e.g. bridged interfaces)
         while origin is not None:
 
             if origin._path is None:
                 break
 
-            path.extend([origin, *origin._path.get_path()])
-            while (len(path) + 1) % 3:
+            path.extend(origin._path.path_objects)
+            while (len(path)) % 3:
                 # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
-                path.append(None)
-            path.append(origin._path.destination)
+                # by inserting empty entries immediately prior to the path's destination node(s)
+                path.append([])
 
-            # Check for bridge interface to continue the trace
-            origin = getattr(origin._path.destination, 'bridge', None)
+            # Check for a bridged relationship to continue the trace
+            destinations = origin._path.destinations
+            if len(destinations) == 1:
+                origin = getattr(destinations[0], 'bridge', None)
+            else:
+                origin = None
 
-        # Return the path as a list of three-tuples (A termination, cable, B termination)
+        # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s))
         return list(zip(*[iter(path)] * 3))
 
-    def get_trace_svg(self, base_url=None, width=None):
-        if width is not None:
-            trace = CableTraceSVG(self, base_url=base_url, width=width)
-        else:
-            trace = CableTraceSVG(self, base_url=base_url)
-        return trace.render()
-
-    @property
-    def path(self):
-        return self._path
-
-    @property
-    def connected_endpoint(self):
+    @cached_property
+    def connected_endpoints(self):
         """
         Caching accessor for the attached CablePath's destination (if any)
         """
-        if not hasattr(self, '_connected_endpoint'):
-            self._connected_endpoint = self._path.destination if self._path else None
-        return self._connected_endpoint
+        return self._path.destinations if self._path else []
 
 
 #
 # Console components
 #
 
-class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
+class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
@@ -276,7 +267,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
         return reverse('dcim:consoleport', kwargs={'pk': self.pk})
 
 
-class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
+class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     """
@@ -307,7 +298,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
 # Power components
 #
 
-class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
+class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
@@ -348,36 +339,57 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
                     'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
                 })
 
+    def get_downstream_powerports(self, leg=None):
+        """
+        Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology
+        below, PP1.get_downstream_powerports() would return PP2-4.
+
+               ---- PO1 <---> PP2
+             /
+        PP1 ------- PO2 <---> PP3
+             \
+               ---- PO3 <---> PP4
+
+        """
+        poweroutlets = self.poweroutlets.filter(cable__isnull=False)
+        if leg:
+            poweroutlets = poweroutlets.filter(feed_leg=leg)
+        if not poweroutlets:
+            return PowerPort.objects.none()
+
+        q = Q()
+        for poweroutlet in poweroutlets:
+            q |= Q(
+                cable=poweroutlet.cable,
+                cable_end=poweroutlet.opposite_cable_end
+            )
+
+        return PowerPort.objects.filter(q)
+
     def get_power_draw(self):
         """
         Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
         """
+        from dcim.models import PowerFeed
+
         # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
         if self.allocated_draw is None and self.maximum_draw is None:
-            poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
-            outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
-            utilization = PowerPort.objects.filter(
-                _link_peer_type=poweroutlet_ct,
-                _link_peer_id__in=outlet_ids
-            ).aggregate(
+            utilization = self.get_downstream_powerports().aggregate(
                 maximum_draw_total=Sum('maximum_draw'),
                 allocated_draw_total=Sum('allocated_draw'),
             )
             ret = {
                 'allocated': utilization['allocated_draw_total'] or 0,
                 'maximum': utilization['maximum_draw_total'] or 0,
-                'outlet_count': len(outlet_ids),
+                'outlet_count': self.poweroutlets.count(),
                 'legs': [],
             }
 
-            # Calculate per-leg aggregates for three-phase feeds
-            if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
+            # Calculate per-leg aggregates for three-phase power feeds
+            if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \
+                    self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE:
                 for leg, leg_name in PowerOutletFeedLegChoices:
-                    outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
-                    utilization = PowerPort.objects.filter(
-                        _link_peer_type=poweroutlet_ct,
-                        _link_peer_id__in=outlet_ids
-                    ).aggregate(
+                    utilization = self.get_downstream_powerports(leg=leg).aggregate(
                         maximum_draw_total=Sum('maximum_draw'),
                         allocated_draw_total=Sum('allocated_draw'),
                     )
@@ -385,7 +397,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
                         'name': leg_name,
                         'allocated': utilization['allocated_draw_total'] or 0,
                         'maximum': utilization['maximum_draw_total'] or 0,
-                        'outlet_count': len(outlet_ids),
+                        'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(),
                     })
 
             return ret
@@ -394,12 +406,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
         return {
             'allocated': self.allocated_draw or 0,
             'maximum': self.maximum_draw or 0,
-            'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
+            'outlet_count': self.poweroutlets.count(),
             'legs': [],
         }
 
 
-class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
+class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     """
@@ -437,9 +449,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
 
         # Validate power port assignment
         if self.power_port and self.power_port.device != self.device:
-            raise ValidationError(
-                "Parent power port ({}) must belong to the same device".format(self.power_port)
-            )
+            raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
 
 
 #
@@ -513,7 +523,7 @@ class BaseInterface(models.Model):
         return self.fhrp_group_assignments.count()
 
 
-class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
+class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint):
     """
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     """
@@ -829,6 +839,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
     def link(self):
         return self.cable or self.wireless_link
 
+    @cached_property
+    def link_peers(self):
+        if self.cable:
+            return super().link_peers
+        if self.wireless_link:
+            # Return the opposite side of the attached wireless link
+            if self.wireless_link.interface_a == self:
+                return [self.wireless_link.interface_b]
+            else:
+                return [self.wireless_link.interface_a]
+        return []
+
     @property
     def l2vpn_termination(self):
         return self.l2vpn_terminations.first()
@@ -838,7 +860,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
 # Pass-through ports
 #
 
-class FrontPort(ModularComponentModel, LinkTermination):
+class FrontPort(ModularComponentModel, CabledObjectModel):
     """
     A pass-through port on the front of a Device.
     """
@@ -891,7 +913,7 @@ class FrontPort(ModularComponentModel, LinkTermination):
             })
 
 
-class RearPort(ModularComponentModel, LinkTermination):
+class RearPort(ModularComponentModel, CabledObjectModel):
     """
     A pass-through port on the rear of a Device.
     """

+ 2 - 2
netbox/dcim/models/power.py

@@ -9,7 +9,7 @@ from dcim.constants import *
 from netbox.config import ConfigItem
 from netbox.models import NetBoxModel
 from utilities.validators import ExclusionValidator
-from .device_components import LinkTermination, PathEndpoint
+from .device_components import CabledObjectModel, PathEndpoint
 
 __all__ = (
     'PowerFeed',
@@ -67,7 +67,7 @@ class PowerPanel(NetBoxModel):
             )
 
 
-class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
+class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
     """
     An electrical circuit delivered from a PowerPanel.
     """

+ 10 - 10
netbox/dcim/models/racks.py

@@ -432,17 +432,17 @@ class Rack(NetBoxModel):
         if not available_power_total:
             return 0
 
-        pf_powerports = PowerPort.objects.filter(
-            _link_peer_type=ContentType.objects.get_for_model(PowerFeed),
-            _link_peer_id__in=powerfeeds.values_list('id', flat=True)
-        )
-        poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
-        allocated_draw_total = PowerPort.objects.filter(
-            _link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
-            _link_peer_id__in=poweroutlets.values_list('id', flat=True)
-        ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
+        powerports = []
+        for powerfeed in powerfeeds:
+            powerports.extend([
+                peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort)
+            ])
+
+        allocated_draw = sum([
+            powerport.get_power_draw()['allocated'] for powerport in powerports
+        ])
 
-        return int(allocated_draw_total / available_power_total * 100)
+        return int(allocated_draw / available_power_total * 100)
 
 
 class RackReservation(NetBoxModel):

+ 37 - 55
netbox/dcim/signals.py

@@ -1,11 +1,11 @@
 import logging
 
-from django.contrib.contenttypes.models import ContentType
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.dispatch import receiver
 
-from .choices import LinkStatusChoices
-from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
+from .choices import CableEndChoices, LinkStatusChoices
+from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
+from .models.cables import trace_paths
 from .utils import create_cablepath, rebuild_paths
 
 
@@ -68,73 +68,55 @@ def clear_virtualchassis_members(instance, **kwargs):
 # Cables
 #
 
-
-@receiver(post_save, sender=Cable)
+@receiver(trace_paths, sender=Cable)
 def update_connected_endpoints(instance, created, raw=False, **kwargs):
     """
-    When a Cable is saved, check for and update its two connected endpoints
+    When a Cable is saved with new terminations, retrace any affected cable paths.
     """
     logger = logging.getLogger('netbox.dcim.cable')
     if raw:
         logger.debug(f"Skipping endpoint updates for imported cable {instance}")
         return
 
-    # Cache the Cable on its two termination points
-    if instance.termination_a.cable != instance:
-        logger.debug(f"Updating termination A for cable {instance}")
-        instance.termination_a.cable = instance
-        instance.termination_a._link_peer = instance.termination_b
-        instance.termination_a.save()
-    if instance.termination_b.cable != instance:
-        logger.debug(f"Updating termination B for cable {instance}")
-        instance.termination_b.cable = instance
-        instance.termination_b._link_peer = instance.termination_a
-        instance.termination_b.save()
-
-    # Create/update cable paths
-    if created:
-        for termination in (instance.termination_a, instance.termination_b):
-            if isinstance(termination, PathEndpoint):
-                create_cablepath(termination)
+    # Update cable paths if new terminations have been set
+    if hasattr(instance, 'a_terminations') or hasattr(instance, 'b_terminations'):
+        a_terminations = []
+        b_terminations = []
+        for t in instance.terminations.all():
+            if t.cable_end == CableEndChoices.SIDE_A:
+                a_terminations.append(t.termination)
+            else:
+                b_terminations.append(t.termination)
+        for nodes in [a_terminations, b_terminations]:
+            # Examine type of first termination to determine object type (all must be the same)
+            if not nodes:
+                continue
+            if isinstance(nodes[0], PathEndpoint):
+                create_cablepath(nodes)
             else:
-                rebuild_paths(termination)
+                rebuild_paths(nodes)
+
+    # Update status of CablePaths if Cable status has been changed
     elif instance.status != instance._orig_status:
-        # We currently don't support modifying either termination of an existing Cable. (This
-        # may change in the future.) However, we do need to capture status changes and update
-        # any CablePaths accordingly.
         if instance.status != LinkStatusChoices.STATUS_CONNECTED:
-            CablePath.objects.filter(path__contains=instance).update(is_active=False)
+            CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
         else:
-            rebuild_paths(instance)
+            rebuild_paths([instance])
 
 
 @receiver(post_delete, sender=Cable)
-def nullify_connected_endpoints(instance, **kwargs):
+def retrace_cable_paths(instance, **kwargs):
     """
-    When a Cable is deleted, check for and update its two connected endpoints
+    When a Cable is deleted, check for and update its connected endpoints
     """
-    logger = logging.getLogger('netbox.dcim.cable')
+    for cablepath in CablePath.objects.filter(_nodes__contains=instance):
+        cablepath.retrace()
 
-    # Disassociate the Cable from its termination points
-    if instance.termination_a is not None:
-        logger.debug(f"Nullifying termination A for cable {instance}")
-        model = instance.termination_a._meta.model
-        model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
-    if instance.termination_b is not None:
-        logger.debug(f"Nullifying termination B for cable {instance}")
-        model = instance.termination_b._meta.model
-        model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
-
-    # Delete and retrace any dependent cable paths
-    for cablepath in CablePath.objects.filter(path__contains=instance):
-        cp = CablePath.from_origin(cablepath.origin)
-        if cp:
-            CablePath.objects.filter(pk=cablepath.pk).update(
-                path=cp.path,
-                destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
-                destination_id=cp.destination.pk if cp.destination else None,
-                is_active=cp.is_active,
-                is_split=cp.is_split
-            )
-        else:
-            cablepath.delete()
+
+@receiver(post_delete, sender=CableTermination)
+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(cable=None, cable_end='')

+ 228 - 170
netbox/dcim/svg/cables.py

@@ -1,12 +1,14 @@
 import svgwrite
-
-from django.conf import settings
 from svgwrite.container import Group, Hyperlink
-from svgwrite.shapes import Line, Rect
+from svgwrite.shapes import Line, Polyline, Rect
 from svgwrite.text import Text
 
+from django.conf import settings
+
+from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from utilities.utils import foreground_color
 
+
 __all__ = (
     'CableTraceSVG',
 )
@@ -15,6 +17,95 @@ __all__ = (
 OFFSET = 0.5
 PADDING = 10
 LINE_HEIGHT = 20
+FANOUT_HEIGHT = 35
+FANOUT_LEG_HEIGHT = 15
+
+
+class Node(Hyperlink):
+    """
+    Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
+
+    Arguments:
+        position: (x, y) coordinates of the box's top left corner
+        width: Box width
+        url: Hyperlink URL
+        color: Box fill color (RRGGBB format)
+        labels: An iterable of text strings. Each label will render on a new line within the box.
+        radius: Box corner radius, for rounded corners (default: 10)
+    """
+
+    def __init__(self, position, width, url, color, labels, radius=10, **extra):
+        super(Node, self).__init__(href=url, target='_blank', **extra)
+
+        x, y = position
+
+        # Add the box
+        dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING)
+        box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}')
+        self.add(box)
+
+        cursor = y + PADDING
+
+        # Add text label(s)
+        for i, label in enumerate(labels):
+            cursor += LINE_HEIGHT
+            text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2)
+            text_color = f'#{foreground_color(color, dark="303030")}'
+            text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
+            self.add(text)
+
+    @property
+    def box(self):
+        return self.elements[0] if self.elements else None
+
+    @property
+    def top_center(self):
+        return self.box['x'] + self.box['width'] / 2, self.box['y']
+
+    @property
+    def bottom_center(self):
+        return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height']
+
+
+class Connector(Group):
+    """
+    Return an SVG group containing a line element and text labels representing a Cable.
+
+    Arguments:
+        color: Cable (line) color
+        url: Hyperlink URL
+        labels: Iterable of text labels
+    """
+
+    def __init__(self, start, url, color, labels=[], **extra):
+        super().__init__(class_='connector', **extra)
+
+        self.start = start
+        self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
+        self.end = (start[0], start[1] + self.height)
+        self.color = color or '000000'
+
+        # Draw a "shadow" line to give the cable a border
+        cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
+        self.add(cable_shadow)
+
+        # Draw the cable
+        cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
+        self.add(cable)
+
+        # Add link
+        link = Hyperlink(href=url, target='_blank')
+
+        # Add text label(s)
+        cursor = start[1]
+        cursor += PADDING * 2
+        for i, label in enumerate(labels):
+            cursor += LINE_HEIGHT
+            text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
+            text = Text(label, insert=text_coords, class_='bold' if not i else [])
+            link.add(text)
+
+        self.add(link)
 
 
 class CableTraceSVG:
@@ -25,7 +116,7 @@ class CableTraceSVG:
     :param width: Width of the generated image (in pixels)
     :param base_url: Base URL for links within the SVG document. If none, links will be relative.
     """
-    def __init__(self, origin, width=400, base_url=None):
+    def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None):
         self.origin = origin
         self.width = width
         self.base_url = base_url.rstrip('/') if base_url is not None else ''
@@ -34,6 +125,11 @@ class CableTraceSVG:
         # Center edges on pixels to render sharp borders
         self.cursor = OFFSET
 
+        # Prep elements lists
+        self.parent_objects = []
+        self.terminations = []
+        self.connectors = []
+
     @property
     def center(self):
         return self.width / 2
@@ -78,95 +174,103 @@ class CableTraceSVG:
             # Other parent object
             return 'e0e0e0'
 
-    def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
+    def draw_parent_objects(self, obj_list):
         """
-        Return an SVG Link element containing a Rect and one or more text labels representing a
-        parent object or cable termination point.
-
-        :param width: Box width
-        :param color: Box fill color
-        :param url: Hyperlink URL
-        :param labels: Iterable of text labels
-        :param y_indent: Vertical indent (for overlapping other boxes) (default: 0)
-        :param padding_multiplier: Add extra vertical padding (default: 1)
-        :param radius: Box corner radius (default: 10)
+        Draw a set of parent objects.
         """
-        self.cursor -= y_indent
-
-        # Create a hyperlink
-        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
-
-        # Add the box
-        position = (
-            OFFSET + (self.width - width) / 2,
-            self.cursor
-        )
-        height = PADDING * padding_multiplier \
-            + LINE_HEIGHT * len(labels) \
-            + PADDING * padding_multiplier
-        box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}')
-        link.add(box)
-        self.cursor += PADDING * padding_multiplier
-
-        # Add text label(s)
-        for i, label in enumerate(labels):
-            self.cursor += LINE_HEIGHT
-            text_coords = (self.center, self.cursor - LINE_HEIGHT / 2)
-            text_color = f'#{foreground_color(color, dark="303030")}'
-            text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
-            link.add(text)
-
-        self.cursor += PADDING * padding_multiplier
-
-        return link
-
-    def _draw_cable(self, color, url, labels):
+        width = self.width / len(obj_list)
+        for i, obj in enumerate(obj_list):
+            node = Node(
+                position=(i * width, self.cursor),
+                width=width,
+                url=f'{self.base_url}{obj.get_absolute_url()}',
+                color=self._get_color(obj),
+                labels=self._get_labels(obj)
+            )
+            self.parent_objects.append(node)
+            if i + 1 == len(obj_list):
+                self.cursor += node.box['height']
+
+    def draw_terminations(self, terminations):
         """
-        Return an SVG group containing a line element and text labels representing a Cable.
-
-        :param color: Cable (line) color
-        :param url: Hyperlink URL
-        :param labels: Iterable of text labels
+        Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
         """
-        group = Group(class_='connector')
-
-        # Draw a "shadow" line to give the cable a border
-        start = (OFFSET + self.center, self.cursor)
-        height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
-        end = (start[0], start[1] + height)
-        cable_shadow = Line(start=start, end=end, class_='cable-shadow')
-        group.add(cable_shadow)
-
-        # Draw the cable
-        cable = Line(start=start, end=end, style=f'stroke: #{color}')
-        group.add(cable)
-
-        self.cursor += PADDING * 2
+        nodes = []
+        nodes_height = 0
+        width = self.width / len(terminations)
+
+        for i, term in enumerate(terminations):
+            node = Node(
+                position=(i * width, self.cursor),
+                width=width,
+                url=f'{self.base_url}{term.get_absolute_url()}',
+                color=self._get_color(term),
+                labels=self._get_labels(term),
+                radius=5
+            )
+            nodes_height = max(nodes_height, node.box['height'])
+            nodes.append(node)
+
+        self.cursor += nodes_height
+        self.terminations.extend(nodes)
+
+        return nodes
+
+    def draw_fanin(self, node, connector):
+        points = (
+            node.bottom_center,
+            (node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
+            connector.start,
+        )
+        self.connectors.extend((
+            Polyline(points=points, class_='cable-shadow'),
+            Polyline(points=points, style=f'stroke: #{connector.color}'),
+        ))
+
+    def draw_fanout(self, node, connector):
+        points = (
+            connector.end,
+            (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
+            node.top_center,
+        )
+        self.connectors.extend((
+            Polyline(points=points, class_='cable-shadow'),
+            Polyline(points=points, style=f'stroke: #{connector.color}'),
+        ))
+
+    def draw_cable(self, cable):
+        labels = [
+            f'Cable {cable}',
+            cable.get_status_display()
+        ]
+        if cable.type:
+            labels.append(cable.get_type_display())
+        if cable.length and cable.length_unit:
+            labels.append(f'{cable.length} {cable.get_length_unit_display()}')
+        connector = Connector(
+            start=(self.center + OFFSET, self.cursor),
+            color=cable.color or '000000',
+            url=f'{self.base_url}{cable.get_absolute_url()}',
+            labels=labels
+        )
 
-        # Add link
-        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+        self.cursor += connector.height
 
-        # Add text label(s)
-        for i, label in enumerate(labels):
-            self.cursor += LINE_HEIGHT
-            text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
-            text = Text(label, insert=text_coords, class_='bold' if not i else [])
-            link.add(text)
+        return connector
 
-        group.add(link)
-        self.cursor += PADDING * 2
-
-        return group
-
-    def _draw_wirelesslink(self, url, labels):
+    def draw_wirelesslink(self, wirelesslink):
         """
         Draw a line with labels representing a WirelessLink.
-
-        :param url: Hyperlink URL
-        :param labels: Iterable of text labels
         """
         group = Group(class_='connector')
 
+        labels = [
+            f'Wireless link {wirelesslink}',
+            wirelesslink.get_status_display()
+        ]
+        if wirelesslink.ssid:
+            labels.append(wirelesslink.ssid)
+
         # Draw the wireless link
         start = (OFFSET + self.center, self.cursor)
         height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
@@ -177,7 +281,7 @@ class CableTraceSVG:
         self.cursor += PADDING * 2
 
         # Add link
-        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+        link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank')
 
         # Add text label(s)
         for i, label in enumerate(labels):
@@ -191,7 +295,7 @@ class CableTraceSVG:
 
         return group
 
-    def _draw_attachment(self):
+    def draw_attachment(self):
         """
         Return an SVG group containing a line element and "Attachment" label.
         """
@@ -216,109 +320,63 @@ class CableTraceSVG:
 
         traced_path = self.origin.trace()
 
-        # Prep elements list
-        parent_objects = []
-        terminations = []
-        connectors = []
-
-        # Iterate through each (term, cable, term) segment in the path
+        # Iterate through each (terms, cable, terms) segment in the path
         for i, segment in enumerate(traced_path):
-            near_end, connector, far_end = segment
+            near_ends, links, far_ends = segment
 
             # Near end parent
             if i == 0:
                 # If this is the first segment, draw the originating termination's parent object
-                parent_object = self._draw_box(
-                    width=self.width,
-                    color=self._get_color(near_end.parent_object),
-                    url=near_end.parent_object.get_absolute_url(),
-                    labels=self._get_labels(near_end.parent_object),
-                    padding_multiplier=2
-                )
-                parent_objects.append(parent_object)
-
-            # Near end termination
-            if near_end is not None:
-                termination = self._draw_box(
-                    width=self.width * .8,
-                    color=self._get_color(near_end),
-                    url=near_end.get_absolute_url(),
-                    labels=self._get_labels(near_end),
-                    y_indent=PADDING,
-                    radius=5
-                )
-                terminations.append(termination)
+                self.draw_parent_objects(set(end.parent_object for end in near_ends))
+
+            # Near end termination(s)
+            terminations = self.draw_terminations(near_ends)
 
             # Connector (a Cable or WirelessLink)
-            if connector is not None:
+            if links:
+                link = links[0]  # Remove Cable from list
 
                 # Cable
-                if type(connector) is Cable:
-                    connector_labels = [
-                        f'Cable {connector}',
-                        connector.get_status_display()
-                    ]
-                    if connector.type:
-                        connector_labels.append(connector.get_type_display())
-                    if connector.length and connector.length_unit:
-                        connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
-                    cable = self._draw_cable(
-                        color=connector.color or '000000',
-                        url=connector.get_absolute_url(),
-                        labels=connector_labels
-                    )
-                    connectors.append(cable)
+                if type(link) is Cable:
+
+                    # Account for fan-ins height
+                    if len(near_ends) > 1:
+                        self.cursor += FANOUT_HEIGHT
+
+                    cable = self.draw_cable(link)
+                    self.connectors.append(cable)
+
+                    # Draw fan-ins
+                    if len(near_ends) > 1:
+                        for term in terminations:
+                            self.draw_fanin(term, cable)
 
                 # WirelessLink
-                elif type(connector) is WirelessLink:
-                    connector_labels = [
-                        f'Wireless link {connector}',
-                        connector.get_status_display()
-                    ]
-                    if connector.ssid:
-                        connector_labels.append(connector.ssid)
-                    wirelesslink = self._draw_wirelesslink(
-                        url=connector.get_absolute_url(),
-                        labels=connector_labels
-                    )
-                    connectors.append(wirelesslink)
-
-                # Far end termination
-                termination = self._draw_box(
-                    width=self.width * .8,
-                    color=self._get_color(far_end),
-                    url=far_end.get_absolute_url(),
-                    labels=self._get_labels(far_end),
-                    radius=5
-                )
-                terminations.append(termination)
+                elif type(link) is WirelessLink:
+                    wirelesslink = self.draw_wirelesslink(link)
+                    self.connectors.append(wirelesslink)
+
+                # Far end termination(s)
+                if len(far_ends) > 1:
+                    self.cursor += FANOUT_HEIGHT
+                    terminations = self.draw_terminations(far_ends)
+                    for term in terminations:
+                        self.draw_fanout(term, cable)
+                else:
+                    self.draw_terminations(far_ends)
 
                 # Far end parent
-                parent_object = self._draw_box(
-                    width=self.width,
-                    color=self._get_color(far_end.parent_object),
-                    url=far_end.parent_object.get_absolute_url(),
-                    labels=self._get_labels(far_end.parent_object),
-                    y_indent=PADDING,
-                    padding_multiplier=2
-                )
-                parent_objects.append(parent_object)
-
-            elif far_end:
+                parent_objects = set(end.parent_object for end in far_ends)
+                self.draw_parent_objects(parent_objects)
+
+            elif far_ends:
 
                 # Attachment
-                attachment = self._draw_attachment()
-                connectors.append(attachment)
+                attachment = self.draw_attachment()
+                self.connectors.append(attachment)
 
                 # ProviderNetwork
-                parent_object = self._draw_box(
-                    width=self.width,
-                    color=self._get_color(far_end),
-                    url=far_end.get_absolute_url(),
-                    labels=self._get_labels(far_end),
-                    padding_multiplier=2
-                )
-                parent_objects.append(parent_object)
+                self.draw_parent_objects(set(end.parent_object for end in far_ends))
 
         # Determine drawing size
         self.drawing = svgwrite.Drawing(
@@ -330,7 +388,7 @@ class CableTraceSVG:
             self.drawing.defs.add(self.drawing.style(css_file.read()))
 
         # Add elements to the drawing in order of depth (Z axis)
-        for element in connectors + parent_objects + terminations:
+        for element in self.connectors + self.parent_objects + self.terminations:
             self.drawing.add(element)
 
         return self.drawing

+ 81 - 28
netbox/dcim/tables/cables.py

@@ -1,56 +1,109 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
+from django.utils.safestring import mark_safe
 
 from dcim.models import Cable
 from netbox.tables import NetBoxTable, columns
 from tenancy.tables import TenantColumn
-from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
+from .template_code import CABLE_LENGTH
 
 __all__ = (
     'CableTable',
 )
 
 
+class CableTerminationsColumn(tables.Column):
+    """
+    Args:
+        cable_end: Which side of the cable to report on (A or B)
+        attr: The CableTermination attribute to return for each instance (returns the termination object by default)
+    """
+    def __init__(self, cable_end, attr='termination', *args, **kwargs):
+        self.cable_end = cable_end
+        self.attr = attr
+        super().__init__(accessor=Accessor('terminations'), *args, **kwargs)
+
+    def _get_terminations(self, manager):
+        terminations = set()
+        for cabletermination in manager.all():
+            if cabletermination.cable_end == self.cable_end:
+                if termination := getattr(cabletermination, self.attr, None):
+                    terminations.add(termination)
+
+        return terminations
+
+    def render(self, value):
+        links = [
+            f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
+        ]
+        return mark_safe('<br />'.join(links) or '&mdash;')
+
+    def value(self, value):
+        return ','.join([str(t) for t in self._get_terminations(value)])
+
+
 #
 # Cables
 #
 
 class CableTable(NetBoxTable):
-    termination_a_parent = tables.TemplateColumn(
-        template_code=CABLE_TERMINATION_PARENT,
-        accessor=Accessor('termination_a'),
+    a_terminations = CableTerminationsColumn(
+        cable_end='A',
         orderable=False,
-        verbose_name='Side A'
+        verbose_name='Termination A'
     )
-    rack_a = tables.Column(
-        accessor=Accessor('termination_a__device__rack'),
+    b_terminations = CableTerminationsColumn(
+        cable_end='B',
         orderable=False,
-        linkify=True,
-        verbose_name='Rack A'
+        verbose_name='Termination B'
     )
-    termination_a = tables.Column(
-        accessor=Accessor('termination_a'),
+    device_a = CableTerminationsColumn(
+        cable_end='A',
+        attr='_device',
         orderable=False,
-        linkify=True,
-        verbose_name='Termination A'
+        verbose_name='Device A'
     )
-    termination_b_parent = tables.TemplateColumn(
-        template_code=CABLE_TERMINATION_PARENT,
-        accessor=Accessor('termination_b'),
+    device_b = CableTerminationsColumn(
+        cable_end='B',
+        attr='_device',
         orderable=False,
-        verbose_name='Side B'
+        verbose_name='Device B'
     )
-    rack_b = tables.Column(
-        accessor=Accessor('termination_b__device__rack'),
+    location_a = CableTerminationsColumn(
+        cable_end='A',
+        attr='_location',
+        orderable=False,
+        verbose_name='Location A'
+    )
+    location_b = CableTerminationsColumn(
+        cable_end='B',
+        attr='_location',
+        orderable=False,
+        verbose_name='Location B'
+    )
+    rack_a = CableTerminationsColumn(
+        cable_end='A',
+        attr='_rack',
+        orderable=False,
+        verbose_name='Rack A'
+    )
+    rack_b = CableTerminationsColumn(
+        cable_end='B',
+        attr='_rack',
         orderable=False,
-        linkify=True,
         verbose_name='Rack B'
     )
-    termination_b = tables.Column(
-        accessor=Accessor('termination_b'),
+    site_a = CableTerminationsColumn(
+        cable_end='A',
+        attr='_site',
         orderable=False,
-        linkify=True,
-        verbose_name='Termination B'
+        verbose_name='Site A'
+    )
+    site_b = CableTerminationsColumn(
+        cable_end='B',
+        attr='_site',
+        orderable=False,
+        verbose_name='Site B'
     )
     status = columns.ChoiceFieldColumn()
     tenant = TenantColumn()
@@ -66,10 +119,10 @@ class CableTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Cable
         fields = (
-            'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
-            'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
+            'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'color', 'length', 'tags',
+            'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
-            'status', 'type',
+            'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
         )

+ 36 - 34
netbox/dcim/tables/template_code.py

@@ -13,15 +13,17 @@ CABLE_LENGTH = """
 {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
 """
 
-CABLE_TERMINATION_PARENT = """
-{% if value.device %}
-    <a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
-{% elif value.circuit %}
-    <a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
-{% elif value.power_panel %}
-    <a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
-{% endif %}
-"""
+# CABLE_TERMINATION_PARENT = """
+# {% with value.0 as termination %}
+#   {% if termination.device %}
+#     <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
+#   {% elif termination.circuit %}
+#     <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a>
+#   {% elif termination.power_panel %}
+#     <a href="{{ termination.power_panel.get_absolute_url }}">{{ termination.power_panel }}</a>
+#   {% endif %}
+# {% endwith %}
+# """
 
 DEVICE_LINK = """
 <a href="{% url 'dcim:device' pk=record.pk %}">
@@ -133,9 +135,9 @@ CONSOLEPORT_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
-            <li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
         </ul>
     </span>
 {% else %}
@@ -165,9 +167,9 @@ CONSOLESERVERPORT_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
-            <li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
         </ul>
     </span>
 {% else %}
@@ -197,8 +199,8 @@ POWERPORT_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
-            <li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.poweroutlet&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerfeed&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
         </ul>
     </span>
 {% else %}
@@ -224,7 +226,7 @@ POWEROUTLET_BUTTONS = """
     <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
     <a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
     {% if not record.mark_connected %}
-        <a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
+        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
             <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
         </a>
     {% else %}
@@ -274,10 +276,10 @@ INTERFACE_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
-            <li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
-            <li><a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
+            <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
         </ul>
     </span>
     {% else %}
@@ -313,12 +315,12 @@ FRONTPORT_BUTTONS = """
                 <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             </button>
             <ul class="dropdown-menu dropdown-menu-end">
-                <li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuittermination&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
             </ul>
         </span>
     {% else %}
@@ -350,12 +352,12 @@ REARPORT_BUTTONS = """
                 <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             </button>
             <ul class="dropdown-menu dropdown-menu-end">
-                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
-                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.interface&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleserverport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.consoleport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.frontport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=dcim.rearport&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ record.pk }}&b_terminations_type=circuits.circuitterminations&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
             </ul>
         </span>
     {% else %}

+ 19 - 19
netbox/dcim/tests/test_api.py

@@ -45,7 +45,7 @@ class Mixins:
                 device=peer_device,
                 name='Peer Termination'
             )
-            cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
+            cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1')
             cable.save()
 
             self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
@@ -55,9 +55,9 @@ class Mixins:
             self.assertHttpStatus(response, status.HTTP_200_OK)
             self.assertEqual(len(response.data), 1)
             segment1 = response.data[0]
-            self.assertEqual(segment1[0]['name'], obj.name)
+            self.assertEqual(segment1[0][0]['name'], obj.name)
             self.assertEqual(segment1[1]['label'], cable.label)
-            self.assertEqual(segment1[2]['name'], peer_obj.name)
+            self.assertEqual(segment1[2][0]['name'], peer_obj.name)
 
 
 class RegionTest(APIViewTestCases.APIViewTestCase):
@@ -1884,33 +1884,33 @@ class CableTest(APIViewTestCases.APIViewTestCase):
         Interface.objects.bulk_create(interfaces)
 
         cables = (
-            Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'),
-            Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'),
-            Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'),
+            Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'),
+            Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'),
+            Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'),
         )
         for cable in cables:
             cable.save()
 
         cls.create_data = [
             {
-                'termination_a_type': 'dcim.interface',
-                'termination_a_id': interfaces[4].pk,
-                'termination_b_type': 'dcim.interface',
-                'termination_b_id': interfaces[14].pk,
+                'a_terminations_type': 'dcim.interface',
+                'a_terminations': [interfaces[4].pk],
+                'b_terminations_type': 'dcim.interface',
+                'b_terminations': [interfaces[14].pk],
                 'label': 'Cable 4',
             },
             {
-                'termination_a_type': 'dcim.interface',
-                'termination_a_id': interfaces[5].pk,
-                'termination_b_type': 'dcim.interface',
-                'termination_b_id': interfaces[15].pk,
+                'a_terminations_type': 'dcim.interface',
+                'a_terminations': [interfaces[5].pk],
+                'b_terminations_type': 'dcim.interface',
+                'b_terminations': [interfaces[15].pk],
                 'label': 'Cable 5',
             },
             {
-                'termination_a_type': 'dcim.interface',
-                'termination_a_id': interfaces[6].pk,
-                'termination_b_type': 'dcim.interface',
-                'termination_b_id': interfaces[16].pk,
+                'a_terminations_type': 'dcim.interface',
+                'a_terminations': [interfaces[6].pk],
+                'b_terminations_type': 'dcim.interface',
+                'b_terminations': [interfaces[16].pk],
                 'label': 'Cable 6',
             },
         ]
@@ -1936,7 +1936,7 @@ class ConnectedDeviceTest(APITestCase):
         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
         self.interface3 = Interface.objects.create(device=self.device1, name='eth1')  # Not connected
 
-        cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
+        cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
         cable.save()
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

Разница между файлами не показана из-за своего большого размера
+ 536 - 210
netbox/dcim/tests/test_cablepaths.py


+ 55 - 37
netbox/dcim/tests/test_filtersets.py

@@ -1950,8 +1950,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         ConsolePort.objects.bulk_create(console_ports)
 
         # Cables
-        Cable(termination_a=console_ports[0], termination_b=console_server_ports[0]).save()
-        Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save()
+        Cable(a_terminations=[console_ports[0]], b_terminations=[console_server_ports[0]]).save()
+        Cable(a_terminations=[console_ports[1]], b_terminations=[console_server_ports[1]]).save()
         # Third port is not connected
 
     def test_name(self):
@@ -2097,8 +2097,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         ConsoleServerPort.objects.bulk_create(console_server_ports)
 
         # Cables
-        Cable(termination_a=console_server_ports[0], termination_b=console_ports[0]).save()
-        Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save()
+        Cable(a_terminations=[console_server_ports[0]], b_terminations=[console_ports[0]]).save()
+        Cable(a_terminations=[console_server_ports[1]], b_terminations=[console_ports[1]]).save()
         # Third port is not connected
 
     def test_name(self):
@@ -2244,8 +2244,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         PowerPort.objects.bulk_create(power_ports)
 
         # Cables
-        Cable(termination_a=power_ports[0], termination_b=power_outlets[0]).save()
-        Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save()
+        Cable(a_terminations=[power_ports[0]], b_terminations=[power_outlets[0]]).save()
+        Cable(a_terminations=[power_ports[1]], b_terminations=[power_outlets[1]]).save()
         # Third port is not connected
 
     def test_name(self):
@@ -2399,8 +2399,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         PowerOutlet.objects.bulk_create(power_outlets)
 
         # Cables
-        Cable(termination_a=power_outlets[0], termination_b=power_ports[0]).save()
-        Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save()
+        Cable(a_terminations=[power_outlets[0]], b_terminations=[power_ports[0]]).save()
+        Cable(a_terminations=[power_outlets[1]], b_terminations=[power_ports[1]]).save()
         # Third port is not connected
 
     def test_name(self):
@@ -2656,8 +2656,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         Interface.objects.bulk_create(interfaces)
 
         # Cables
-        Cable(termination_a=interfaces[0], termination_b=interfaces[3]).save()
-        Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save()
+        Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
+        Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
         # Third pair is not connected
 
     def test_name(self):
@@ -2932,8 +2932,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         FrontPort.objects.bulk_create(front_ports)
 
         # Cables
-        Cable(termination_a=front_ports[0], termination_b=front_ports[3]).save()
-        Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save()
+        Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
+        Cable(a_terminations=[front_ports[1]], b_terminations=[front_ports[4]]).save()
         # Third port is not connected
 
     def test_name(self):
@@ -3078,8 +3078,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         RearPort.objects.bulk_create(rear_ports)
 
         # Cables
-        Cable(termination_a=rear_ports[0], termination_b=rear_ports[3]).save()
-        Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save()
+        Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[3]]).save()
+        Cable(a_terminations=[rear_ports[1]], b_terminations=[rear_ports[4]]).save()
         # Third port is not connected
 
     def test_name(self):
@@ -3663,6 +3663,21 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Site.objects.bulk_create(sites)
 
+        locations = (
+            Location(name='Location 1', site=sites[0], slug='location-1'),
+            Location(name='Location 2', site=sites[1], slug='location-1'),
+            Location(name='Location 3', site=sites[2], slug='location-1'),
+        )
+        for location in locations:
+            location.save()
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0], location=locations[0]),
+            Rack(name='Rack 2', site=sites[1], location=locations[1]),
+            Rack(name='Rack 3', site=sites[2], location=locations[2]),
+        )
+        Rack.objects.bulk_create(racks)
+
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1'),
             Tenant(name='Tenant 2', slug='tenant-2'),
@@ -3670,24 +3685,17 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Tenant.objects.bulk_create(tenants)
 
-        racks = (
-            Rack(name='Rack 1', site=sites[0]),
-            Rack(name='Rack 2', site=sites[1]),
-            Rack(name='Rack 3', site=sites[2]),
-        )
-        Rack.objects.bulk_create(racks)
-
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
-            Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
-            Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
-            Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=1),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=2),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=1),
+            Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=2),
+            Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=1),
+            Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=2),
         )
         Device.objects.bulk_create(devices)
 
@@ -3711,13 +3719,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
 
         # Cables
-        Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
-        Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
-        Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
-        Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
-        Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
-        Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
-        Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
+        Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[2]], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(a_terminations=[interfaces[3]], b_terminations=[interfaces[4]], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(a_terminations=[interfaces[5]], b_terminations=[interfaces[6]], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(a_terminations=[interfaces[7]], b_terminations=[interfaces[8]], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+        Cable(a_terminations=[interfaces[9]], b_terminations=[interfaces[10]], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+        Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+        Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
 
     def test_label(self):
         params = {'label': ['Cable 1', 'Cable 2']}
@@ -3759,6 +3767,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'rack': [racks[0].name, racks[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        params = {'location': [locations[0].name, locations[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
     def test_site(self):
         site = Site.objects.all()[:2]
         params = {'site_id': [site[0].pk, site[1].pk]}
@@ -3780,7 +3795,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_termination_ids(self):
-        interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
+        interface_ids = CableTermination.objects.filter(
+            cable__in=Cable.objects.all()[:3],
+            cable_end='A'
+        ).values_list('termination_id', flat=True)
         params = {
             'termination_a_type': 'dcim.interface',
             'termination_a_id': list(interface_ids),
@@ -3924,8 +3942,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
             PowerPort(device=device, name='Power Port 2'),
         ]
         PowerPort.objects.bulk_create(power_ports)
-        Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save()
-        Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save()
+        Cable(a_terminations=[power_feeds[0]], b_terminations=[power_ports[0]]).save()
+        Cable(a_terminations=[power_feeds[1]], b_terminations=[power_ports[1]]).save()
 
     def test_name(self):
         params = {'name': ['Power Feed 1', 'Power Feed 2']}

+ 24 - 68
netbox/dcim/tests/test_models.py

@@ -457,7 +457,7 @@ class CableTestCase(TestCase):
         self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
         self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
-        self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
+        self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
         self.cable.save()
 
         self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
@@ -493,12 +493,14 @@ class CableTestCase(TestCase):
         """
         When a new Cable is created, it must be cached on either termination point.
         """
-        interface1 = Interface.objects.get(pk=self.interface1.pk)
-        interface2 = Interface.objects.get(pk=self.interface2.pk)
-        self.assertEqual(self.cable.termination_a, interface1)
-        self.assertEqual(interface1._link_peer, interface2)
-        self.assertEqual(self.cable.termination_b, interface2)
-        self.assertEqual(interface2._link_peer, interface1)
+        self.interface1.refresh_from_db()
+        self.interface2.refresh_from_db()
+        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):
         """
@@ -510,50 +512,33 @@ 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)
-
-    def test_cabletermination_deletion(self):
-        """
-        When a CableTermination object is deleted, its attached Cable (if any) must also be deleted.
-        """
-        self.interface1.delete()
-        cable = Cable.objects.filter(pk=self.cable.pk).first()
-        self.assertIsNone(cable)
-
-    def test_cable_validates_compatible_types(self):
-        """
-        The clean method should have a check to ensure only compatible port types can be connected by a cable
-        """
-        # An interface cannot be connected to a power port
-        cable = Cable(termination_a=self.interface1, termination_b=self.power_port1)
-        with self.assertRaises(ValidationError):
-            cable.clean()
+        self.assertListEqual(interface2.link_peers, [])
 
-    def test_cable_cannot_have_the_same_terminination_on_both_ends(self):
+    def test_cable_validates_same_parent_object(self):
         """
-        A cable cannot be made with the same A and B side terminations
+        The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
         """
-        cable = Cable(termination_a=self.interface1, termination_b=self.interface1)
+        cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
         with self.assertRaises(ValidationError):
             cable.clean()
 
-    def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self):
+    def test_cable_validates_same_type(self):
         """
-        A cable cannot connect a front port to its corresponding rear port
+        The clean method should ensure that all terminations at either end of a Cable are of the same type.
         """
-        cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1)
+        cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1])
         with self.assertRaises(ValidationError):
             cable.clean()
 
-    def test_cable_cannot_terminate_to_an_existing_connection(self):
+    def test_cable_validates_compatible_types(self):
         """
-        Either side of a cable cannot be terminated when that side already has a connection
+        The clean method should have a check to ensure only compatible port types can be connected by a cable
         """
-        # Try to create a cable with the same interface terminations
-        cable = Cable(termination_a=self.interface2, termination_b=self.interface1)
+        # An interface cannot be connected to a power port, for example
+        cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
         with self.assertRaises(ValidationError):
             cable.clean()
 
@@ -561,45 +546,16 @@ class CableTestCase(TestCase):
         """
         Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
         """
-        cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3)
+        cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3])
         with self.assertRaises(ValidationError):
             cable.clean()
 
-    def test_rearport_connections(self):
-        """
-        Test various combinations of RearPort connections.
-        """
-        # Connecting a single-position RearPort to a multi-position RearPort is ok
-        Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
-
-        # Connecting a single-position RearPort to an Interface is ok
-        Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean()
-
-        # Connecting a single-position RearPort to a CircuitTermination is ok
-        Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
-
-        # Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
-        Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
-
-        # Connecting a multi-position RearPort to an Interface is ok
-        Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
-
-        # Connecting a multi-position RearPort to a CircuitTermination is ok
-        Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
-
-        # Connecting a two-position RearPort to a three-position RearPort is NOT ok
-        with self.assertRaises(
-            ValidationError,
-                msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
-        ):
-            Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
-
     def test_cable_cannot_terminate_to_a_virtual_interface(self):
         """
         A cable cannot terminate to a virtual interface
         """
         virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
-        cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
+        cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface])
         with self.assertRaises(ValidationError):
             cable.clean()
 
@@ -608,6 +564,6 @@ class CableTestCase(TestCase):
         A cable cannot terminate to a wireless interface
         """
         wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
-        cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
+        cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
         with self.assertRaises(ValidationError):
             cable.clean()

+ 14 - 15
netbox/dcim/tests/test_views.py

@@ -1961,7 +1961,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=consoleport.device,
             name='Console Server Port 1'
         )
-        Cable(termination_a=consoleport, termination_b=consoleserverport).save()
+        Cable(a_terminations=[consoleport], b_terminations=[consoleserverport]).save()
 
         response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk}))
         self.assertHttpStatus(response, 200)
@@ -2017,7 +2017,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=consoleserverport.device,
             name='Console Port 1'
         )
-        Cable(termination_a=consoleserverport, termination_b=consoleport).save()
+        Cable(a_terminations=[consoleserverport], b_terminations=[consoleport]).save()
 
         response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk}))
         self.assertHttpStatus(response, 200)
@@ -2079,7 +2079,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=powerport.device,
             name='Power Outlet 1'
         )
-        Cable(termination_a=powerport, termination_b=poweroutlet).save()
+        Cable(a_terminations=[powerport], b_terminations=[poweroutlet]).save()
 
         response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk}))
         self.assertHttpStatus(response, 200)
@@ -2144,7 +2144,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     def test_trace(self):
         poweroutlet = PowerOutlet.objects.first()
         powerport = PowerPort.objects.first()
-        Cable(termination_a=poweroutlet, termination_b=powerport).save()
+        Cable(a_terminations=[poweroutlet], b_terminations=[powerport]).save()
 
         response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk}))
         self.assertHttpStatus(response, 200)
@@ -2268,7 +2268,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
         interface1, interface2 = Interface.objects.all()[:2]
-        Cable(termination_a=interface1, termination_b=interface2).save()
+        Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
 
         response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
         self.assertHttpStatus(response, 200)
@@ -2339,7 +2339,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=frontport.device,
             name='Interface 1'
         )
-        Cable(termination_a=frontport, termination_b=interface).save()
+        Cable(a_terminations=[frontport], b_terminations=[interface]).save()
 
         response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk}))
         self.assertHttpStatus(response, 200)
@@ -2397,7 +2397,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=rearport.device,
             name='Interface 1'
         )
-        Cable(termination_a=rearport, termination_b=interface).save()
+        Cable(a_terminations=[rearport], b_terminations=[interface]).save()
 
         response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk}))
         self.assertHttpStatus(response, 200)
@@ -2630,19 +2630,18 @@ class CableTestCase(
         )
         Interface.objects.bulk_create(interfaces)
 
-        Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save()
-        Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
-        Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
+        Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save()
+        Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save()
+        Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save()
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         interface_ct = ContentType.objects.get_for_model(Interface)
         cls.form_data = {
+            # TODO: Revisit this limitation
             # Changing terminations not supported when editing an existing Cable
-            'termination_a_type': interface_ct.pk,
-            'termination_a_id': interfaces[0].pk,
-            'termination_b_type': interface_ct.pk,
-            'termination_b_id': interfaces[3].pk,
+            'a_terminations': interfaces[0].pk,
+            'b_terminations': interfaces[3].pk,
             'type': CableTypeChoices.TYPE_CAT6,
             'status': LinkStatusChoices.STATUS_PLANNED,
             'label': 'Label',
@@ -2864,7 +2863,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             device=device,
             name='Power Port 1'
         )
-        Cable(termination_a=powerfeed, termination_b=powerport).save()
+        Cable(a_terminations=[powerfeed], b_terminations=[powerport]).save()
 
         response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
         self.assertHttpStatus(response, 200)

+ 1 - 8
netbox/dcim/urls.py

@@ -294,7 +294,6 @@ urlpatterns = [
     path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
     path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
-    path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
     path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
 
     # Console server ports
@@ -310,7 +309,6 @@ urlpatterns = [
     path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
     path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
     path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
-    path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
     path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
 
     # Power ports
@@ -326,7 +324,6 @@ urlpatterns = [
     path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
     path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
-    path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
     path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
 
     # Power outlets
@@ -342,7 +339,6 @@ urlpatterns = [
     path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
     path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
     path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
-    path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
     path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
 
     # Interfaces
@@ -358,7 +354,6 @@ urlpatterns = [
     path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
     path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
-    path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
     path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
 
     # Front ports
@@ -374,7 +369,6 @@ urlpatterns = [
     path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
     path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
     path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
-    path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
     # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
 
     # Rear ports
@@ -390,7 +384,6 @@ urlpatterns = [
     path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
     path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
     path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
-    path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
     path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
 
     # Module bays
@@ -447,6 +440,7 @@ urlpatterns = [
 
     # Cables
     path('cables/', views.CableListView.as_view(), name='cable_list'),
+    path('cables/add/', views.CableEditView.as_view(), name='cable_add'),
     path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
     path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
     path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
@@ -500,6 +494,5 @@ urlpatterns = [
     path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
     path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
     path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
-    path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
 
 ]

+ 15 - 11
netbox/dcim/utils.py

@@ -1,3 +1,5 @@
+import itertools
+
 from django.contrib.contenttypes.models import ContentType
 from django.db import transaction
 
@@ -29,27 +31,29 @@ def path_node_to_object(repr):
     return ct.model_class().objects.get(pk=object_id)
 
 
-def create_cablepath(node):
+def create_cablepath(terminations):
     """
-    Create CablePaths for all paths originating from the specified node.
+    Create CablePaths for all paths originating from the specified set of nodes.
+
+    :param terminations: Iterable of CableTermination objects
     """
     from dcim.models import CablePath
 
-    cp = CablePath.from_origin(node)
+    cp = CablePath.from_origin(terminations)
     if cp:
         cp.save()
 
 
-def rebuild_paths(obj):
+def rebuild_paths(terminations):
     """
-    Rebuild all CablePaths which traverse the specified node
+    Rebuild all CablePaths which traverse the specified nodes.
     """
     from dcim.models import CablePath
 
-    cable_paths = CablePath.objects.filter(path__contains=obj)
+    for obj in terminations:
+        cable_paths = CablePath.objects.filter(_nodes__contains=obj)
 
-    with transaction.atomic():
-        for cp in cable_paths:
-            cp.delete()
-            if cp.origin:
-                create_cablepath(cp.origin)
+        with transaction.atomic():
+            for cp in cable_paths:
+                cp.delete()
+                create_cablepath(cp.origins)

+ 43 - 67
netbox/dcim/views.py

@@ -12,7 +12,7 @@ from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 
-from circuits.models import Circuit
+from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView
 from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
@@ -28,6 +28,18 @@ from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import *
 
+CABLE_TERMINATION_TYPES = {
+    'dcim.consoleport': ConsolePort,
+    'dcim.consoleserverport': ConsoleServerPort,
+    'dcim.powerport': PowerPort,
+    'dcim.poweroutlet': PowerOutlet,
+    'dcim.interface': Interface,
+    'dcim.frontport': FrontPort,
+    'dcim.rearport': RearPort,
+    'dcim.powerfeed': PowerFeed,
+    'circuits.circuittermination': CircuitTermination,
+}
+
 
 class DeviceComponentsView(generic.ObjectChildrenView):
     queryset = Device.objects.all()
@@ -1717,7 +1729,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
 
     def get_extra_context(self, request, instance):
         interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
-            '_path__destination'
+            '_path'
         ).exclude(
             type__in=NONCONNECTABLE_IFACE_TYPES
         )
@@ -2744,7 +2756,10 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
 #
 
 class CableListView(generic.ObjectListView):
-    queryset = Cable.objects.all()
+    queryset = Cable.objects.prefetch_related(
+        'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
+        'terminations___site',
+    )
     filterset = filtersets.CableFilterSet
     filterset_form = forms.CableFilterForm
     table = tables.CableTable
@@ -2777,7 +2792,7 @@ class PathTraceView(generic.ObjectView):
 
         # Otherwise, find all CablePaths which traverse the specified object
         else:
-            related_paths = CablePath.objects.filter(path__contains=instance).prefetch_related('origin')
+            related_paths = CablePath.objects.filter(_nodes__contains=instance)
             # Check for specification of a particular path (when tracing pass-through ports)
             try:
                 path_id = int(request.GET.get('cablepath_id'))
@@ -2798,8 +2813,8 @@ class PathTraceView(generic.ObjectView):
         total_length, is_definitive = path.get_total_length() if path else (None, False)
 
         # Determine the path to the SVG trace image
-        api_viewname = f"{path.origin._meta.app_label}-api:{path.origin._meta.model_name}-trace"
-        svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origin.pk})}?render=svg"
+        api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace"
+        svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg"
 
         return {
             'path': path,
@@ -2810,77 +2825,38 @@ class PathTraceView(generic.ObjectView):
         }
 
 
-class CableCreateView(generic.ObjectEditView):
+class CableEditView(generic.ObjectEditView):
     queryset = Cable.objects.all()
-    template_name = 'dcim/cable_connect.html'
+    template_name = 'dcim/cable_edit.html'
 
     def dispatch(self, request, *args, **kwargs):
 
-        # Set the form class based on the type of component being connected
-        self.form = {
-            'console-port': forms.ConnectCableToConsolePortForm,
-            'console-server-port': forms.ConnectCableToConsoleServerPortForm,
-            'power-port': forms.ConnectCableToPowerPortForm,
-            'power-outlet': forms.ConnectCableToPowerOutletForm,
-            'interface': forms.ConnectCableToInterfaceForm,
-            'front-port': forms.ConnectCableToFrontPortForm,
-            'rear-port': forms.ConnectCableToRearPortForm,
-            'power-feed': forms.ConnectCableToPowerFeedForm,
-            'circuit-termination': forms.ConnectCableToCircuitTerminationForm,
-        }[kwargs.get('termination_b_type')]
+        # If creating a new Cable, initialize the form class using URL query params
+        if 'pk' not in kwargs:
+            self.form = forms.get_cable_form(
+                a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')),
+                b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type'))
+            )
 
         return super().dispatch(request, *args, **kwargs)
 
     def get_object(self, **kwargs):
-        # Always return a new instance
-        return self.queryset.model()
-
-    def alter_object(self, obj, request, url_args, url_kwargs):
-        termination_a_type = url_kwargs.get('termination_a_type')
-        termination_a_id = url_kwargs.get('termination_a_id')
-        termination_b_type_name = url_kwargs.get('termination_b_type')
-        self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
+        """
+        Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView
+        doesn't currently provide a hook for dynamic class resolution.
+        """
+        obj = super().get_object(**kwargs)
 
-        # Initialize Cable termination attributes
-        obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
-        obj.termination_b_type = self.termination_b_type
+        if obj.pk:
+            # TODO: Optimize this logic
+            termination_a = obj.terminations.filter(cable_end='A').first()
+            a_type = termination_a.termination._meta.model if termination_a else None
+            termination_b = obj.terminations.filter(cable_end='B').first()
+            b_type = termination_b.termination._meta.model if termination_a else None
+            self.form = forms.get_cable_form(a_type, b_type)
 
         return obj
 
-    def get(self, request, *args, **kwargs):
-        obj = self.get_object(**kwargs)
-        obj = self.alter_object(obj, request, args, kwargs)
-
-        # Parse initial data manually to avoid setting field values as lists
-        initial_data = {k: request.GET[k] for k in request.GET}
-
-        # Set initial site and rack based on side A termination (if not already set)
-        termination_a_site = getattr(obj.termination_a.parent_object, 'site', None)
-        if termination_a_site and 'termination_b_region' not in initial_data:
-            initial_data['termination_b_region'] = termination_a_site.region
-        if termination_a_site and 'termination_b_site_group' not in initial_data:
-            initial_data['termination_b_site_group'] = termination_a_site.group
-        if 'termination_b_site' not in initial_data:
-            initial_data['termination_b_site'] = termination_a_site
-        if 'termination_b_rack' not in initial_data:
-            initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None)
-
-        form = self.form(instance=obj, initial=initial_data)
-
-        return render(request, self.template_name, {
-            'obj': obj,
-            'obj_type': Cable._meta.verbose_name,
-            'termination_b_type': self.termination_b_type.name,
-            'form': form,
-            'return_url': self.get_return_url(request, obj),
-        })
-
-
-class CableEditView(generic.ObjectEditView):
-    queryset = Cable.objects.all()
-    form = forms.CableForm
-    template_name = 'dcim/cable_edit.html'
-
 
 class CableDeleteView(generic.ObjectDeleteView):
     queryset = Cable.objects.all()
@@ -2893,14 +2869,14 @@ class CableBulkImportView(generic.BulkImportView):
 
 
 class CableBulkEditView(generic.BulkEditView):
-    queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
+    queryset = Cable.objects.prefetch_related('terminations')
     filterset = filtersets.CableFilterSet
     table = tables.CableTable
     form = forms.CableBulkEditForm
 
 
 class CableBulkDeleteView(generic.BulkDeleteView):
-    queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
+    queryset = Cable.objects.prefetch_related('terminations')
     filterset = filtersets.CableFilterSet
     table = tables.CableTable
 

+ 2 - 2
netbox/netbox/middleware.py

@@ -179,8 +179,8 @@ class ExceptionHandlingMiddleware:
     def process_exception(self, request, exception):
 
         # Handle exceptions that occur from REST API requests
-        if is_api_request(request):
-            return rest_api_server_error(request)
+        # if is_api_request(request):
+        #     return rest_api_server_error(request)
 
         # Don't catch exceptions when in debug mode
         if settings.DEBUG:

+ 3 - 5
netbox/netbox/views/__init__.py

@@ -3,7 +3,6 @@ import sys
 
 from django.conf import settings
 from django.core.cache import cache
-from django.db.models import F
 from django.http import HttpResponseServerError
 from django.shortcuts import redirect, render
 from django.template import loader
@@ -37,14 +36,13 @@ class HomeView(View):
             return redirect("login")
 
         connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
-            _path__destination_id__isnull=False
+            _path__is_complete=True
         )
         connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
-            _path__destination_id__isnull=False
+            _path__is_complete=True
         )
         connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
-            _path__destination_id__isnull=False,
-            pk__lt=F('_path__destination_id')
+            _path__is_complete=True
         )
 
         def build_stats():

+ 1 - 1
netbox/project-static/dist/cable_trace.css

@@ -1 +1 @@
-:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}
+:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}

+ 5 - 1
netbox/project-static/styles/cable-trace.scss

@@ -55,7 +55,11 @@ svg {
   line {
     stroke-width: 5px;
   }
-  line.cable-shadow {
+  polyline {
+    fill: none;
+    stroke-width: 5px;
+  }
+  .cable-shadow {
     stroke: var(--nbx-trace-cable-shadow);
     stroke-width: 7px;
   }

+ 8 - 9
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
@@ -70,10 +69,10 @@
                       <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
                     </button>
                     <ul class="dropdown-menu">
-                      <li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Interface</a></li>
-                      <li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a></li>
-                      <li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
-                      <li><a class="dropdown-item" href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
+                      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Interface</a></li>
+                      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a></li>
+                      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
+                      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
                     </ul>
                   </div>
                 {% endif %}

+ 71 - 77
netbox/templates/dcim/cable.html

@@ -5,85 +5,79 @@
 {% load plugins %}
 
 {% block content %}
-    <div class="row">
-        <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Cable
-                </h5>
-                <div class="card-body">
-                    <table class="table table-hover attr-table">
-                        <tr>
-                            <th scope="row">Type</th>
-                            <td>{{ object.get_type_display|placeholder }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Status</th>
-                            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Tenant</th>
-                            <td>
-                                {% if object.tenant.group %}
-                                    {{ object.tenant.group|linkify }} /
-                                {% endif %}
-                                {{ object.tenant|linkify|placeholder }}
-                            </td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Label</th>
-                            <td>{{ object.label|placeholder }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Color</th>
-                            <td>
-                                {% if object.color %}
-                                    <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
-                                {% else %}
-                                    {{ ''|placeholder }}
-                                {% endif %}
-                            </td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Length</th>
-                            <td>
-                                {% if object.length %}
-                                    {{ object.length|floatformat }} {{ object.get_length_unit_display }}
-                                {% else %}
-                                    {{ ''|placeholder }}
-                                {% endif %}
-                            </td>
-                        </tr>
-                    </table>
-                </div>
-            </div>
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' %}
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Termination A
-                </h5>
-                <div class="card-body">
-                {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %}
-                </div>
-            </div>
-            <div class="card">
-                <h5 class="card-header">
-                    Termination B
-                </h5>
-                <div class="card-body">
-                {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %}
-                </div>
-            </div>
-            {% plugin_right_page object %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Cable</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Type</th>
+              <td>{{ object.get_type_display|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Status</th>
+              <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+            </tr>
+            <tr>
+              <th scope="row">Tenant</th>
+              <td>
+                {% if object.tenant.group %}
+                  {{ object.tenant.group|linkify }} /
+                {% endif %}
+                {{ object.tenant|linkify|placeholder }}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Label</th>
+              <td>{{ object.label|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Color</th>
+              <td>
+                {% if object.color %}
+                  <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Length</th>
+              <td>
+                {% if object.length %}
+                  {{ object.length|floatformat }} {{ object.get_length_unit_display }}
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
+          </table>
         </div>
+      </div>
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
     </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Termination A</h5>
+        <div class="card-body">
+          {% include 'dcim/inc/cable_termination.html' with terminations=object.get_a_terminations %}
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">Termination B</h5>
+        <div class="card-body">
+          {% include 'dcim/inc/cable_termination.html' with terminations=object.get_b_terminations %}
         </div>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
     </div>
+  </div>
 {% endblock %}

+ 0 - 186
netbox/templates/dcim/cable_connect.html

@@ -1,186 +0,0 @@
-{% extends 'base/layout.html' %}
-{% load static %}
-{% load helpers %}
-{% load form_helpers %}
-
-{% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}
-
-{% block tabs %}
-<ul class="nav nav-tabs px-3">
-  <li class="nav-item" role="presentation">
-    <a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
-  </li>
-</ul>
-{% endblock %}
-
-{% block content-wrapper %}
-  <div class="tab-content">
-    {% with termination_a=form.instance.termination_a %}
-      {% render_errors form %}
-      <form method="post">
-      {% csrf_token %}
-      {% for field in form.hidden_fields %}
-          {{ field }}
-      {% endfor %}
-      <div class="row my-3">
-          <div class="col col-md-5">
-              <div class="card h-100">
-                  <h5 class="card-header offset-sm-3">A Side</h5>
-                  <div class="card-body">
-                      {% if termination_a.device %}
-                          {# Device component #}
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Region</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.device.site.region }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Site Group</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.device.site.group }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Site</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.device.site }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Location</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.device.location|default:"None" }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Rack</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.device.rack|default:"None" }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Device</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.device }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Type</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a|meta:"verbose_name"|capfirst }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label text-lg-end">Name</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a }}" disabled />
-                              </div>
-                          </div>
-                      {% else %}
-                          {# Circuit termination #}
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label">Site</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.site }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label">Provider</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.circuit.provider }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label">Circuit</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.circuit.cid }}" disabled />
-                              </div>
-                          </div>
-                          <div class="row mb-3">
-                              <label class="col-sm-3 col-form-label">Side</label>
-                              <div class="col">
-                                  <input class="form-control" value="{{ termination_a.term_side }}" disabled />
-                              </div>
-                          </div>
-                      {% endif %}
-                  </div>
-              </div>
-          </div>
-          <div class="col col-md-2 flex-column justify-content-center align-items-center d-none d-md-flex">
-              <i class="mdi mdi-swap-horizontal-bold mdi-48px"></i>
-          </div>
-          <div class="col col-md-5">
-              <div class="card h-100">
-                  <h5 class="card-header offset-sm-3">B Side</h5>
-                  <div class="card-body">
-                      {% if tabs %}
-                          <ul class="nav nav-tabs">
-                              {% for url, link in tabs %}
-                                  <li class="nav-item" role="presentation">
-                                      <a class="nav-link" href="{{ url }}">{{ link }}</a>
-                                  </li>
-                              {% endfor %}
-                          </ul>
-                      {% endif %}
-                      {% if 'termination_b_provider' in form.fields %}
-                          {% render_field form.termination_b_provider %}
-                      {% endif %}
-                      {% if 'termination_b_region' in form.fields %}
-                          {% render_field form.termination_b_region %}
-                      {% endif %}
-                      {% if 'termination_b_sitegroup' in form.fields %}
-                          {% render_field form.termination_b_sitegroup %}
-                      {% endif %}
-                      {% if 'termination_b_site' in form.fields %}
-                          {% render_field form.termination_b_site %}
-                      {% endif %}
-                      {% if 'termination_b_location' in form.fields %}
-                          {% render_field form.termination_b_location %}
-                      {% endif %}
-                      {% if 'termination_b_rack' in form.fields %}
-                          {% render_field form.termination_b_rack %}
-                      {% endif %}
-                      {% if 'termination_b_device' in form.fields %}
-                          {% render_field form.termination_b_device %}
-                      {% endif %}
-                      {% if 'termination_b_type' in form.fields %}
-                          {% render_field form.termination_b_type %}
-                      {% endif %}
-                      {% if 'termination_b_powerpanel' in form.fields %}
-                          {% render_field form.termination_b_powerpanel %}
-                      {% endif %}
-                      {% if 'termination_b_circuit' in form.fields %}
-                          {% render_field form.termination_b_circuit %}
-                      {% endif %}
-                      <div class="row mb-3">
-                          <label class="col-sm-3 col-form-label text-lg-end">Type</label>
-                          <div class="col">
-                              <input class="form-control" value="{{ termination_b_type|capfirst }}" disabled />
-                          </div>
-                      </div>
-                      {% render_field form.termination_b_id %}
-                  </div>
-              </div>
-          </div>
-      </div>
-      <div class="row my-3 justify-content-center">
-        <div class="col col-md-8">
-          <div class="card">
-            <h5 class="card-header offset-sm-3">Cable</h5>
-            <div class="card-body">
-              {% include 'dcim/inc/cable_form.html' %}
-            </div>
-          </div>
-        </div>
-      </div>
-      <div class="row my-3">
-        <div class="col col-md-12 text-center">
-          <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-          <button type="submit" name="_update" class="btn btn-primary">Connect</button>
-        </div>
-      </div>
-    </form>
-    {% endwith %}
-  </div>
-{% endblock %}

+ 123 - 3
netbox/templates/dcim/cable_edit.html

@@ -1,5 +1,125 @@
-{% extends 'generic/object_edit.html' %}
+{% extends 'base/layout.html' %}
+{% load static %}
+{% load helpers %}
+{% load form_helpers %}
 
-{% block form %}
-  {% include 'dcim/inc/cable_form.html' %}
+{% block title %}Connect Cable{% endblock %}
+
+{% block tabs %}
+<ul class="nav nav-tabs px-3">
+  <li class="nav-item" role="presentation">
+    <a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
+  </li>
+</ul>
+{% endblock %}
+
+{% block content-wrapper %}
+  <div class="tab-content">
+    {% render_errors form %}
+    <form method="post">
+      {% csrf_token %}
+      {% for field in form.hidden_fields %}
+          {{ field }}
+      {% endfor %}
+      <div class="row my-3">
+          <div class="col col-md-5">
+              <div class="card h-100">
+                  <h5 class="card-header offset-sm-3">A Side</h5>
+                  <div class="card-body">
+                      {% render_field form.termination_a_region %}
+                      {% render_field form.termination_a_sitegroup %}
+                      {% render_field form.termination_a_site %}
+                      {% render_field form.termination_a_location %}
+                      {% if 'termination_a_rack' in form.fields %}
+                          {% render_field form.termination_a_rack %}
+                      {% endif %}
+                      {% if 'termination_a_device' in form.fields %}
+                          {% render_field form.termination_a_device %}
+                      {% endif %}
+                      {% if 'termination_a_powerpanel' in form.fields %}
+                          {% render_field form.termination_a_powerpanel %}
+                      {% endif %}
+                      {% if 'termination_a_provider' in form.fields %}
+                          {% render_field form.termination_a_provider %}
+                      {% endif %}
+                      {% if 'termination_a_circuit' in form.fields %}
+                          {% render_field form.termination_a_circuit %}
+                      {% endif %}
+                      {% render_field form.a_terminations %}
+                  </div>
+              </div>
+          </div>
+          <div class="col col-md-2 flex-column justify-content-center align-items-center d-none d-md-flex">
+              <i class="mdi mdi-swap-horizontal-bold mdi-48px"></i>
+          </div>
+          <div class="col col-md-5">
+              <div class="card h-100">
+                  <h5 class="card-header offset-sm-3">B Side</h5>
+                  <div class="card-body">
+                      {% render_field form.termination_b_region %}
+                      {% render_field form.termination_b_sitegroup %}
+                      {% render_field form.termination_b_site %}
+                      {% render_field form.termination_b_location %}
+                      {% if 'termination_b_rack' in form.fields %}
+                          {% render_field form.termination_b_rack %}
+                      {% endif %}
+                      {% if 'termination_b_device' in form.fields %}
+                          {% render_field form.termination_b_device %}
+                      {% endif %}
+                      {% if 'termination_b_powerpanel' in form.fields %}
+                          {% render_field form.termination_b_powerpanel %}
+                      {% endif %}
+                      {% if 'termination_b_provider' in form.fields %}
+                          {% render_field form.termination_b_provider %}
+                      {% endif %}
+                      {% if 'termination_b_circuit' in form.fields %}
+                          {% render_field form.termination_b_circuit %}
+                      {% endif %}
+                      {% render_field form.b_terminations %}
+                  </div>
+              </div>
+          </div>
+      </div>
+      <div class="row my-3 justify-content-center">
+        <div class="col col-md-8">
+          <div class="card">
+            <h5 class="card-header offset-sm-3">Cable</h5>
+            <div class="card-body">
+              {% render_field form.status %}
+              {% render_field form.type %}
+              {% render_field form.tenant_group %}
+              {% render_field form.tenant %}
+              {% render_field form.label %}
+              {% render_field form.color %}
+              <div class="row mb-3">
+                <label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
+                <div class="col-md-5">
+                  {{ form.length }}
+                </div>
+                <div class="col-md-4">
+                  {{ form.length_unit }}
+                </div>
+                <div class="invalid-feedback"></div>
+              </div>
+              {% render_field form.tags %}
+              {% if form.custom_fields %}
+                <div class="field-group">
+                  <div class="row mb-3">
+                    <h5 class="offset-sm-3">Custom Fields</h5>
+                  </div>
+                  {% render_custom_fields form %}
+                </div>
+              {% endif %}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="row my-3">
+        <div class="col col-md-12 text-center">
+          <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
+          <button type="submit" name="_update" class="btn btn-primary">Connect</button>
+        </div>
+      </div>
+    </form>
+  </div>
 {% endblock %}

+ 3 - 18
netbox/templates/dcim/consoleport.html

@@ -111,28 +111,13 @@
                                     </button>
                                     <ul class="dropdown-menu dropdown-menu-end">
                                         <li>
-                                            <a
-                                                class="dropdown-item"
-                                                href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}"
-                                            >
-                                                Console Server Port
-                                            </a>
+                                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Server Port</a>
                                         </li>
                                         <li>
-                                            <a
-                                                class="dropdown-item"
-                                                href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}"
-                                            >
-                                                Front Port
-                                            </a>
+                                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
                                         </li>
                                         <li>
-                                            <a
-                                                class="dropdown-item"
-                                                href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}"
-                                            >
-                                                Rear Port
-                                            </a>
+                                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
                                         </li>
                                     </ul>
                                 </div>

+ 3 - 18
netbox/templates/dcim/consoleserverport.html

@@ -113,28 +113,13 @@
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
-                                        <a
-                                            class="dropdown-item"
-                                            href="{% url 'dcim:consoleserverport_connect' termination_a_id=object.pk termination_b_type='console-port' %}?return_url={{ object.get_absolute_url }}"
-                                        >
-                                            Console Port
-                                        </a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Port</a>
                                     </li>
                                     <li>
-                                        <a
-                                            class="dropdown-item"
-                                            href="{% url 'dcim:consoleserverport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}"
-                                        >
-                                            Front Port
-                                        </a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
                                     </li>
                                     <li>
-                                        <a
-                                            class="dropdown-item"
-                                            href="{% url 'dcim:consoleserverport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}"
-                                        >
-                                            Rear Port
-                                        </a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
                                     </li>
                                 </ul>
                             </div>

+ 6 - 6
netbox/templates/dcim/frontport.html

@@ -105,22 +105,22 @@
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">Interface</a>
+                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}">Console Server Port</a>
+                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}">Console Server Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='console-port' %}?return_url={{ object.get_absolute_url }}">Console Port</a>
+                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}">Console Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">Front Port</a>
+                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">Rear Port</a>
+                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:frontport_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">Circuit Termination</a>
+                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
                                     </li>
                                 </ul>
                             </div>

+ 0 - 27
netbox/templates/dcim/inc/cable_form.html

@@ -1,27 +0,0 @@
-{% load form_helpers %}
-
-{% render_field form.status %}
-{% render_field form.type %}
-{% render_field form.tenant_group %}
-{% render_field form.tenant %}
-{% render_field form.label %}
-{% render_field form.color %}
-<div class="row mb-3">
-    <label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
-    <div class="col-md-5">
-        {{ form.length }}
-    </div>
-    <div class="col-md-4">
-        {{ form.length_unit }}
-    </div>
-    <div class="invalid-feedback"></div>
-</div>
-{% render_field form.tags %}
-{% if form.custom_fields %}
-  <div class="field-group">
-    <div class="row mb-3">
-      <h5 class="offset-sm-3">Custom Fields</h5>
-    </div>
-    {% render_custom_fields form %}
-  </div>
-{% endif %}

+ 55 - 39
netbox/templates/dcim/inc/cable_termination.html

@@ -1,42 +1,58 @@
 {% load helpers %}
 <table class="table table-hover panel-body attr-table">
-    {% if termination.device %}
-        {# Device component #}
-        <tr>
-            <td>Device</td>
-            <td>{{ termination.device|linkify }}</td>
-        </tr>
-        <tr>
-            <td>Site</td>
-            <td>{{ termination.device.site|linkify }}</td>
-        </tr>
-        {% if termination.device.rack %}
-            <tr>
-                <td>Rack</td>
-                <td>{{ termination.device.rack|linkify }}</td>
-            </tr>
-        {% endif %}
-        <tr>
-            <td>Type</td>
-            <td>{{ termination|meta:"verbose_name"|capfirst }}</td>
-        </tr>
-        <tr>
-            <td>Component</td>
-            <td>{{ termination|linkify }}</td>
-        </tr>
-    {% else %}
-        {# Circuit termination #}
-        <tr>
-            <td>Provider</td>
-            <td>{{ termination.circuit.provider|linkify }}</td>
-        </tr>
-        <tr>
-            <td>Circuit</td>
-            <td>{{ termination.circuit|linkify }}</td>
-        </tr>
-        <tr>
-            <td>Termination</td>
-            <td>{{ termination }}</td>
-        </tr>
-    {% endif %}
+  {% if terminations.0.device %}
+    {# Device component #}
+    <tr>
+      <td>Site</td>
+      <td>{{ terminations.0.device.site|linkify }}</td>
+    </tr>
+    <tr>
+      <td>Rack</td>
+      <td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
+    </tr>
+    <tr>
+      <td>Device</td>
+      <td>{{ terminations.0.device|linkify }}</td>
+    </tr>
+    <tr>
+      <td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
+      <td>
+        {% for term in terminations %}
+          {{ term|linkify }}{% if not forloop.last %},{% endif %}
+        {% endfor %}
+      </td>
+    </tr>
+  {% elif terminations.0.power_panel %}
+    {# Power feed #}
+    <tr>
+      <td>Site</td>
+      <td>{{ terminations.0.power_panel.site|linkify }}</td>
+    </tr>
+    <tr>
+      <td>Power Panel</td>
+      <td>{{ terminations.0.power_panel|linkify }}</td>
+    </tr>
+    <tr>
+      <td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
+      <td>
+        {% for term in terminations %}
+          {{ term|linkify }}{% if not forloop.last %},{% endif %}
+        {% endfor %}
+      </td>
+    </tr>
+  {% else %}
+    {# Circuit termination #}
+    <tr>
+      <td>Provider</td>
+      <td>{{ terminations.0.circuit.provider|linkify }}</td>
+    </tr>
+    <tr>
+      <td>Circuit</td>
+      <td>
+        {% for term in terminations %}
+          {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
+        {% endfor %}
+      </td>
+    </tr>
+  {% endif %}
 </table>

+ 4 - 12
netbox/templates/dcim/interface.html

@@ -263,24 +263,16 @@
                     </button>
                     <ul class="dropdown-menu dropdown-menu-end">
                       <li>
-                        <a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">
-                          Interface
-                        </a>
+                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
                       </li>
                       <li>
-                        <a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">
-                          Front Port
-                        </a>
+                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
                       </li>
                       <li>
-                        <a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">
-                          Rear Port
-                        </a>
+                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
                       </li>
                       <li>
-                        <a class="dropdown-item" href="{% url 'dcim:interface_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">
-                          Circuit Termination
-                        </a>
+                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
                       </li>
                     </ul>
                   </div>

+ 1 - 2
netbox/templates/dcim/powerfeed.html

@@ -158,8 +158,7 @@
             {% if not object.mark_connected and not object.cable %}
             <div class="card-footer">
             {% if perms.dcim.add_cable %}
-                <a href="{% url 'dcim:powerfeed_connect' termination_a_id=object.pk termination_b_type='power-port' %}?return_url={{ object.get_absolute_url }}"
-                class="btn btn-primary btn-sm float-end">
+                <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerfeed&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm float-end">
                     <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
                 </a>
                     {% endif %}

+ 1 - 1
netbox/templates/dcim/poweroutlet.html

@@ -111,7 +111,7 @@
                     <div class="text-muted">
                         Not Connected
                         {% if perms.dcim.add_cable %}
-                            <a href="{% url 'dcim:poweroutlet_connect' termination_a_id=object.pk termination_b_type='power-port' %}?return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
+                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
                                 <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
                             </a>
                         {% endif %}

+ 2 - 2
netbox/templates/dcim/powerport.html

@@ -117,10 +117,10 @@
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:powerport_connect' termination_a_id=object.pk termination_b_type='power-outlet' %}?return_url={{ object.get_absolute_url }}">Power Outlet</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:powerport_connect' termination_a_id=object.pk termination_b_type='power-feed' %}?return_url={{ object.get_absolute_url }}">Power Feed</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
                                     </li>
                                 </ul>
                             </span>

+ 4 - 4
netbox/templates/dcim/rearport.html

@@ -101,16 +101,16 @@
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='interface' %}?return_url={{ object.get_absolute_url }}">Interface</a>
+                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}">Front Port</a>
+                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}">Rear Port</a>
+                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
                                     </li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:rearport_connect' termination_a_id=object.pk termination_b_type='circuit-termination' %}?return_url={{ object.get_absolute_url }}">Circuit Termination</a>
+                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
                                     </li>
                                 </ul>
                             </span>

+ 4 - 14
netbox/wireless/signals.py

@@ -25,18 +25,16 @@ 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
     if created:
         for interface in (instance.interface_a, instance.interface_b):
-            create_cablepath(interface)
+            create_cablepath([interface])
 
 
 @receiver(post_delete, sender=WirelessLink)
@@ -48,19 +46,11 @@ 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(path__contains=instance):
+    for cablepath in CablePath.objects.filter(_nodes__contains=instance):
         cablepath.delete()

Некоторые файлы не были показаны из-за большого количества измененных файлов