ソースを参照

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

Closes #9102: Add support for multi-termination cable ends
Jeremy Stretch 3 年 前
コミット
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.
 * 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).
 * 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
 ### New Features
 
 
@@ -19,6 +41,8 @@
 
 
 #### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074))
 #### 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
 ### Enhancements
 
 
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
@@ -55,23 +79,83 @@
 ### REST API Changes
 ### REST API Changes
 
 
 * Added the following endpoints:
 * Added the following endpoints:
+    * `/api/dcim/cable-terminations/`
     * `/api/ipam/l2vpns/`
     * `/api/ipam/l2vpns/`
     * `/api/ipam/l2vpn-terminations/`
     * `/api/ipam/l2vpn-terminations/`
 * circuits.Circuit
 * circuits.Circuit
     * Added optional `termination_date` field
     * Added optional `termination_date` field
 * circuits.CircuitTermination
 * 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
 * dcim.Device
     * The `position` field has been changed from an integer to a decimal
     * The `position` field has been changed from an integer to a decimal
 * dcim.DeviceType
 * dcim.DeviceType
     * The `u_height` field has been changed from an integer to a decimal
     * 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
 * 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 optional `poe_mode` and `poe_type` fields
     * Added the `l2vpn_termination` read-only field
     * Added the `l2vpn_termination` read-only field
 * dcim.Location
 * dcim.Location
     * Added required `status` field (default value: `active`)
     * 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
 * dcim.Rack
     * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
     * 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
 * extras.ConfigContext
     * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
     * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
 * extras.CustomField
 * 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.choices import CircuitStatusChoices
 from circuits.models import *
 from circuits.models import *
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 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.models import ASN
 from ipam.api.nested_serializers import NestedASNSerializer
 from ipam.api.nested_serializers import NestedASNSerializer
 from netbox.api import ChoiceField, SerializedPKRelatedField
 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 tenancy.api.nested_serializers import NestedTenantSerializer
 from .nested_serializers import *
 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')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
     circuit = NestedCircuitSerializer()
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
     provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
     provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
-    cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         fields = [
             'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
             '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):
 class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = CircuitTermination.objects.prefetch_related(
     queryset = CircuitTermination.objects.prefetch_related(
-        'circuit', 'site', 'provider_network', 'cable'
+        'circuit', 'site', 'provider_network', 'cable__terminations'
     )
     )
     serializer_class = serializers.CircuitTerminationSerializer
     serializer_class = serializers.CircuitTerminationSerializer
     filterset_class = filtersets.CircuitTerminationFilterSet
     filterset_class = filtersets.CircuitTerminationFilterSet

+ 3 - 3
netbox/circuits/filtersets.py

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

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

@@ -1,4 +1,5 @@
 from circuits import filtersets, models
 from circuits import filtersets, models
+from dcim.graphql.mixins import CabledObjectMixin
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 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:
     class Meta:
         model = models.CircuitTermination
         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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('extras', '0076_configcontext_locations'),
-        ('circuits', '0036_circuit_termination_date'),
+        ('circuits', '0035_provider_asns'),
     ]
     ]
 
 
     operations = [
     operations = [
+        migrations.AddField(
+            model_name='circuit',
+            name='termination_date',
+            field=models.DateField(blank=True, null=True),
+        ),
         migrations.AddField(
         migrations.AddField(
             model_name='circuittermination',
             model_name='circuittermination',
             name='custom_field_data',
             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 django.urls import reverse
 
 
 from circuits.choices import *
 from circuits.choices import *
-from dcim.models import LinkTermination
+from dcim.models import CabledObjectModel
 from netbox.models import (
 from netbox.models import (
     ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
     ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
 )
 )
@@ -149,7 +149,7 @@ class CircuitTermination(
     TagsMixin,
     TagsMixin,
     WebhooksMixin,
     WebhooksMixin,
     ChangeLoggedModel,
     ChangeLoggedModel,
-    LinkTermination
+    CabledObjectModel
 ):
 ):
     circuit = models.ForeignKey(
     circuit = models.ForeignKey(
         to='circuits.Circuit',
         to='circuits.Circuit',

+ 1 - 1
netbox/circuits/signals.py

@@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs):
     if not raw:
     if not raw:
         peer_termination = instance.get_peer_termination()
         peer_termination = instance.get_peer_termination()
         if 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)
         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):
     def test_term_side(self):
         params = {'term_side': 'A'}
         params = {'term_side': 'A'}

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

@@ -246,7 +246,7 @@ class CircuitTerminationTestCase(
             device=device,
             device=device,
             name='Interface 1'
             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}))
         response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)

+ 1 - 2
netbox/circuits/urls.py

@@ -1,6 +1,6 @@
 from django.urls import path
 from django.urls import path
 
 
-from dcim.views import CableCreateView, PathTraceView
+from dcim.views import PathTraceView
 from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
 from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
 from . import views
 from . import views
 from .models import *
 from .models import *
@@ -60,7 +60,6 @@ urlpatterns = [
     path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
     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>/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: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}),
     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 *
 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)
     _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
         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.
         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)
     @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
     def get__occupied(self, obj):
     def get__occupied(self, obj):
         return obj._occupied
         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.
         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']}
             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)
     @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
 # Device components
 #
 #
 
 
-class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
@@ -701,18 +711,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ
         allow_null=True,
         allow_null=True,
         required=False
         required=False
     )
     )
-    cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
             '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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
@@ -729,18 +739,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
         allow_null=True,
         allow_null=True,
         required=False
         required=False
     )
     )
-    cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
             '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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
@@ -761,21 +771,18 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co
         allow_blank=True,
         allow_blank=True,
         required=False
         required=False
     )
     )
-    cable = NestedCableSerializer(
-        read_only=True
-    )
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg',
             '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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
@@ -787,19 +794,18 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         allow_blank=True,
         allow_blank=True,
         required=False
         required=False
     )
     )
-    cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
             '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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
@@ -825,7 +831,6 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
     )
     )
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
     l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True)
-    cable = NestedCableSerializer(read_only=True)
     wireless_link = NestedWirelessLinkSerializer(read_only=True)
     wireless_link = NestedWirelessLinkSerializer(read_only=True)
     wireless_lans = SerializedPKRelatedField(
     wireless_lans = SerializedPKRelatedField(
         queryset=WirelessLAN.objects.all(),
         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',
             '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',
             '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',
             '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):
     def validate(self, data):
@@ -861,7 +867,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         return super().validate(data)
         return super().validate(data)
 
 
 
 
-class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
+class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
@@ -869,13 +875,12 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
         allow_null=True
         allow_null=True
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
-    cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description',
             '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',
             'last_updated', '_occupied',
         ]
         ]
 
 
@@ -891,7 +896,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name', 'label']
         fields = ['id', 'url', 'display', 'name', 'label']
 
 
 
 
-class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
+class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     module = ComponentNestedModuleSerializer(
     module = ComponentNestedModuleSerializer(
@@ -900,14 +905,13 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
     rear_port = FrontPortRearPortSerializer()
     rear_port = FrontPortRearPortSerializer()
-    cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = [
         fields = [
             'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
             '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):
 class CableSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
     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)
     status = ChoiceField(choices=LinkStatusChoices, required=False)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
@@ -1005,33 +1005,46 @@ class CableSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         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']}
         context = {'request': self.context['request']}
-        data = serializer(termination, context=context).data
+        data = serializer(terms, context=context, many=True).data
 
 
         return 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)
     @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)
     @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):
 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:
     class Meta:
-        model = CablePath
+        model = CableTermination
         fields = [
         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)
     @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']}
         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)
     @swagger_serializer_method(serializer_or_field=serializers.ListField)
     def get_path(self, obj):
     def get_path(self, obj):
         ret = []
         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']}
             context = {'request': self.context['request']}
-            ret.append(serializer(node, context=context).data)
+            ret.append(serializer(nodes, context=context, many=True).data)
         return ret
         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')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
     power_panel = NestedPowerPanelSerializer()
     power_panel = NestedPowerPanelSerializer()
     rack = NestedRackSerializer(
     rack = NestedRackSerializer(
@@ -1153,13 +1160,12 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn
         choices=PowerFeedPhaseChoices,
         choices=PowerFeedPhaseChoices,
         default=PowerFeedPhaseChoices.PHASE_SINGLE
         default=PowerFeedPhaseChoices.PHASE_SINGLE
     )
     )
-    cable = NestedCableSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = PowerFeed
         model = PowerFeed
         fields = [
         fields = [
             'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
             '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
 # Cables
 router.register('cables', views.CableViewSet)
 router.register('cables', views.CableViewSet)
+router.register('cable-terminations', views.CableTerminationViewSet)
 
 
 # Virtual chassis
 # Virtual chassis
 router.register('virtual-chassis', views.VirtualChassisViewSet)
 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 circuits.models import Circuit
 from dcim import filtersets
 from dcim import filtersets
+from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.models import *
+from dcim.svg import CableTraceSVG
 from extras.api.views import ConfigContextQuerySetMixin
 from extras.api.views import ConfigContextQuerySetMixin
 from ipam.models import Prefix, VLAN
 from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -51,37 +53,30 @@ class PathEndpointMixin(object):
         # Initialize the path array
         # Initialize the path array
         path = []
         path = []
 
 
+        # Render SVG image if requested
         if request.GET.get('render', None) == 'svg':
         if request.GET.get('render', None) == 'svg':
-            # Render SVG
             try:
             try:
-                width = min(int(request.GET.get('width')), 1600)
+                width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH))
             except (ValueError, TypeError):
             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():
         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
                 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:
             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:
             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)
         return Response(path)
 
 
@@ -94,7 +89,7 @@ class PassThroughPortMixin(object):
         Return all CablePaths which traverse a given pass-through port.
         Return all CablePaths which traverse a given pass-through port.
         """
         """
         obj = get_object_or_404(self.queryset, pk=pk)
         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)
         serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
 
 
         return Response(serializer.data)
         return Response(serializer.data)
@@ -557,7 +552,7 @@ class ModuleViewSet(NetBoxModelViewSet):
 
 
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsolePort.objects.prefetch_related(
     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
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filtersets.ConsolePortFilterSet
     filterset_class = filtersets.ConsolePortFilterSet
@@ -566,7 +561,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = ConsoleServerPort.objects.prefetch_related(
     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
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filtersets.ConsoleServerPortFilterSet
     filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -575,7 +570,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerPort.objects.prefetch_related(
     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
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filtersets.PowerPortFilterSet
     filterset_class = filtersets.PowerPortFilterSet
@@ -584,7 +579,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerOutlet.objects.prefetch_related(
     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
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filtersets.PowerOutletFilterSet
     filterset_class = filtersets.PowerOutletFilterSet
@@ -593,8 +588,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = Interface.objects.prefetch_related(
     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
     serializer_class = serializers.InterfaceSerializer
     filterset_class = filtersets.InterfaceFilterSet
     filterset_class = filtersets.InterfaceFilterSet
@@ -603,7 +598,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = FrontPort.objects.prefetch_related(
     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
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filtersets.FrontPortFilterSet
     filterset_class = filtersets.FrontPortFilterSet
@@ -612,7 +607,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 
 
 class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
 class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     queryset = RearPort.objects.prefetch_related(
     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
     serializer_class = serializers.RearPortSerializer
     filterset_class = filtersets.RearPortFilterSet
     filterset_class = filtersets.RearPortFilterSet
@@ -657,14 +652,18 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class CableViewSet(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
     serializer_class = serializers.CableSerializer
     filterset_class = filtersets.CableFilterSet
     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
 # Virtual chassis
 #
 #
@@ -698,7 +697,7 @@ class PowerPanelViewSet(NetBoxModelViewSet):
 
 
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
     queryset = PowerFeed.objects.prefetch_related(
     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
     serializer_class = serializers.PowerFeedSerializer
     filterset_class = filtersets.PowerFeedFilterSet
     filterset_class = filtersets.PowerFeedFilterSet
@@ -758,13 +757,13 @@ class ConnectedDeviceViewSet(ViewSet):
             device=peer_device,
             device=peer_device,
             name=peer_interface_name
             name=peer_interface_name
         )
         )
-        endpoint = peer_interface.connected_endpoint
+        endpoints = peer_interface.connected_endpoints
 
 
         # If an Interface, return the parent device
         # 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 = get_object_or_404(
                 Device.objects.restrict(request.user, 'view'),
                 Device.objects.restrict(request.user, 'view'),
-                pk=endpoint.device_id
+                pk=endpoints[0].device_id
             )
             )
             return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
             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
 # PowerFeeds
 #
 #

+ 2 - 0
netbox/dcim/constants.py

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

+ 62 - 38
netbox/dcim/filtersets.py

@@ -21,6 +21,7 @@ from .models import *
 
 
 __all__ = (
 __all__ = (
     'CableFilterSet',
     'CableFilterSet',
+    'CabledObjectFilterSet',
     'CableTerminationFilterSet',
     'CableTerminationFilterSet',
     'ConsoleConnectionFilterSet',
     'ConsoleConnectionFilterSet',
     'ConsolePortFilterSet',
     'ConsolePortFilterSet',
@@ -1117,7 +1118,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
     )
     )
 
 
 
 
-class CableTerminationFilterSet(django_filters.FilterSet):
+class CabledObjectFilterSet(django_filters.FilterSet):
     cabled = django_filters.BooleanFilter(
     cabled = django_filters.BooleanFilter(
         field_name='cable',
         field_name='cable',
         lookup_expr='isnull',
         lookup_expr='isnull',
@@ -1140,7 +1141,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
 class ConsolePortFilterSet(
 class ConsolePortFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1150,13 +1151,13 @@ class ConsolePortFilterSet(
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
-        fields = ['id', 'name', 'label', 'description']
+        fields = ['id', 'name', 'label', 'description', 'cable_end']
 
 
 
 
 class ConsoleServerPortFilterSet(
 class ConsoleServerPortFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1166,13 +1167,13 @@ class ConsoleServerPortFilterSet(
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ['id', 'name', 'label', 'description']
+        fields = ['id', 'name', 'label', 'description', 'cable_end']
 
 
 
 
 class PowerPortFilterSet(
 class PowerPortFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1182,13 +1183,13 @@ class PowerPortFilterSet(
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
-        fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
+        fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end']
 
 
 
 
 class PowerOutletFilterSet(
 class PowerOutletFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1202,13 +1203,13 @@ class PowerOutletFilterSet(
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
-        fields = ['id', 'name', 'label', 'feed_leg', 'description']
+        fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
 
 
 
 
 class InterfaceFilterSet(
 class InterfaceFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet,
+    CabledObjectFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
     # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
     # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
@@ -1288,7 +1289,7 @@ class InterfaceFilterSet(
         model = Interface
         model = Interface
         fields = [
         fields = [
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
             '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):
     def filter_device(self, queryset, name, value):
@@ -1342,7 +1343,7 @@ class InterfaceFilterSet(
 class FrontPortFilterSet(
 class FrontPortFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet
+    CabledObjectFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         choices=PortTypeChoices,
@@ -1351,13 +1352,13 @@ class FrontPortFilterSet(
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
-        fields = ['id', 'name', 'label', 'type', 'color', 'description']
+        fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
 
 
 
 
 class RearPortFilterSet(
 class RearPortFilterSet(
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
-    CableTerminationFilterSet
+    CabledObjectFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         choices=PortTypeChoices,
@@ -1366,7 +1367,7 @@ class RearPortFilterSet(
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
-        fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
+        fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end']
 
 
 
 
 class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@@ -1514,10 +1515,18 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
 
 
 
 
 class CableFilterSet(TenancyFilterSet, 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(
     type = django_filters.MultipleChoiceFilter(
         choices=CableTypeChoices
         choices=CableTypeChoices
     )
     )
@@ -1528,44 +1537,57 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         choices=ColorChoices
         choices=ColorChoices
     )
     )
     device_id = MultiValueNumberFilter(
     device_id = MultiValueNumberFilter(
-        method='filter_device'
+        method='filter_by_termination'
     )
     )
     device = MultiValueCharFilter(
     device = MultiValueCharFilter(
-        method='filter_device',
+        method='filter_by_termination',
         field_name='device__name'
         field_name='device__name'
     )
     )
     rack_id = MultiValueNumberFilter(
     rack_id = MultiValueNumberFilter(
-        method='filter_device',
-        field_name='device__rack_id'
+        method='filter_by_termination',
+        field_name='rack_id'
     )
     )
     rack = MultiValueCharFilter(
     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(
     site_id = MultiValueNumberFilter(
-        method='filter_device',
-        field_name='device__site_id'
+        method='filter_by_termination',
+        field_name='site_id'
     )
     )
     site = MultiValueCharFilter(
     site = MultiValueCharFilter(
-        method='filter_device',
-        field_name='device__site__slug'
+        method='filter_by_termination',
+        field_name='site__slug'
     )
     )
 
 
     class Meta:
     class Meta:
         model = Cable
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(label__icontains=value)
         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):
 class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@@ -1625,7 +1647,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
-class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
+class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='power_panel__site__region',
         field_name='power_panel__site__region',
@@ -1679,7 +1701,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEn
 
 
     class Meta:
     class Meta:
         model = PowerFeed
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         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 circuits.models import Circuit, CircuitTermination, Provider
 from dcim.models import *
 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
     model = Cable
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (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')),
         ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -747,13 +747,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         },
         },
         label=_('Site')
         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(
     rack_id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         required=False,
         required=False,
         label=_('Rack'),
         label=_('Rack'),
         null_option='None',
         null_option='None',
         query_params={
         query_params={
-            'site_id': '$site_id'
+            'site_id': '$site_id',
+            'location_id': '$location_id',
         }
         }
     )
     )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
@@ -761,8 +771,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         required=False,
         required=False,
         query_params={
         query_params={
             'site_id': '$site_id',
             'site_id': '$site_id',
-            'tenant_id': '$tenant_id',
+            'location_id': '$location_id',
             'rack_id': '$rack_id',
             'rack_id': '$rack_id',
+            'tenant_id': '$tenant_id',
         },
         },
         label=_('Device')
         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 ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
+from .mixins import CabledObjectMixin
 
 
 __all__ = (
 __all__ = (
     'CableType',
     'CableType',
@@ -99,7 +100,15 @@ class CableType(NetBoxObjectType):
         return self.length_unit or None
         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:
     class Meta:
         model = models.ConsolePort
         model = models.ConsolePort
@@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
         return self.type or None
         return self.type or None
 
 
 
 
-class ConsoleServerPortType(ComponentObjectType):
+class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.ConsoleServerPort
         model = models.ConsoleServerPort
@@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType):
         return self.airflow or None
         return self.airflow or None
 
 
 
 
-class FrontPortType(ComponentObjectType):
+class FrontPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.FrontPort
         model = models.FrontPort
@@ -219,7 +228,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.FrontPortTemplateFilterSet
         filterset_class = filtersets.FrontPortTemplateFilterSet
 
 
 
 
-class InterfaceType(IPAddressesMixin, ComponentObjectType):
+class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.Interface
         model = models.Interface
@@ -322,7 +331,7 @@ class PlatformType(OrganizationalObjectType):
         filterset_class = filtersets.PlatformFilterSet
         filterset_class = filtersets.PlatformFilterSet
 
 
 
 
-class PowerFeedType(NetBoxObjectType):
+class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerFeed
         model = models.PowerFeed
@@ -330,7 +339,7 @@ class PowerFeedType(NetBoxObjectType):
         filterset_class = filtersets.PowerFeedFilterSet
         filterset_class = filtersets.PowerFeedFilterSet
 
 
 
 
-class PowerOutletType(ComponentObjectType):
+class PowerOutletType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerOutlet
         model = models.PowerOutlet
@@ -366,7 +375,7 @@ class PowerPanelType(NetBoxObjectType):
         filterset_class = filtersets.PowerPanelFilterSet
         filterset_class = filtersets.PowerPanelFilterSet
 
 
 
 
-class PowerPortType(ComponentObjectType):
+class PowerPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerPort
         model = models.PowerPort
@@ -418,7 +427,7 @@ class RackRoleType(OrganizationalObjectType):
         filterset_class = filtersets.RackRoleFilterSet
         filterset_class = filtersets.RackRoleFilterSet
 
 
 
 
-class RearPortType(ComponentObjectType):
+class RearPortType(ComponentObjectType, CabledObjectMixin):
 
 
     class Meta:
     class Meta:
         model = models.RearPort
         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}...')
             self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
             i = 0
             i = 0
             for i, obj in enumerate(origins, start=1):
             for i, obj in enumerate(origins, start=1):
-                create_cablepath(obj)
+                create_cablepath([obj])
                 if not i % 100:
                 if not i % 100:
                     self.draw_progress_bar(i * 100 / origins_count)
                     self.draw_progress_bar(i * 100 / origins_count)
             self.draw_progress_bar(100)
             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 collections import defaultdict
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.db.models import Sum
 from django.db.models import Sum
+from django.dispatch import Signal
 from django.urls import reverse
 from django.urls import reverse
 
 
 from dcim.choices import *
 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 dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
 from netbox.models import NetBoxModel
 from netbox.models import NetBoxModel
 from utilities.fields import ColorField
 from utilities.fields import ColorField
+from utilities.querysets import RestrictedQuerySet
 from utilities.utils import to_meters
 from utilities.utils import to_meters
-from .devices import Device
+from wireless.models import WirelessLink
 from .device_components import FrontPort, RearPort
 from .device_components import FrontPort, RearPort
 
 
-
 __all__ = (
 __all__ = (
     'Cable',
     'Cable',
     'CablePath',
     'CablePath',
+    'CableTermination',
 )
 )
 
 
 
 
+trace_paths = Signal()
+
+
 #
 #
 # Cables
 # Cables
 #
 #
@@ -32,28 +38,6 @@ class Cable(NetBoxModel):
     """
     """
     A physical connection between two endpoints.
     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(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=CableTypeChoices,
         choices=CableTypeChoices,
@@ -96,31 +80,11 @@ class Cable(NetBoxModel):
         blank=True,
         blank=True,
         null=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:
     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)
         super().__init__(*args, **kwargs)
 
 
         # A copy of the PK to be used by __str__ in case the object is deleted
         # 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
         # Cache the original status so we can check later if it's been changed
         self._orig_status = self.status
         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):
     def __str__(self):
         pk = self.pk or self._pk
         pk = self.pk or self._pk
@@ -151,123 +108,41 @@ class Cable(NetBoxModel):
         return reverse('dcim:cable', args=[self.pk])
         return reverse('dcim:cable', args=[self.pk])
 
 
     def clean(self):
     def clean(self):
-        from circuits.models import CircuitTermination
-
         super().clean()
         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
         # Validate length and length_unit
         if self.length is not None and not self.length_unit:
         if self.length is not None and not self.length_unit:
             raise ValidationError("Must specify a unit when setting a cable length")
             raise ValidationError("Must specify a unit when setting a cable length")
         elif self.length is None:
         elif self.length is None:
             self.length_unit = ''
             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):
     def save(self, *args, **kwargs):
+        _created = self.pk is None
 
 
         # Store the given length (if any) in meters for use in database ordering
         # Store the given length (if any) in meters for use in database ordering
         if self.length and self.length_unit:
         if self.length and self.length_unit:
@@ -275,199 +150,454 @@ class Cable(NetBoxModel):
         else:
         else:
             self._abs_length = None
             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)
         super().save(*args, **kwargs)
 
 
         # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
         # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
         self._pk = self.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):
     def get_status_color(self):
         return LinkStatusChoices.colors.get(self.status)
         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):
 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:
     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:
     This path would be expressed as:
 
 
     CablePath(
     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(
     is_active = models.BooleanField(
         default=False
         default=False
     )
     )
+    is_complete = models.BooleanField(
+        default=False
+    )
     is_split = models.BooleanField(
     is_split = models.BooleanField(
         default=False
         default=False
     )
     )
-
-    class Meta:
-        unique_together = ('origin_type', 'origin_id')
+    _nodes = PathField()
 
 
     def __str__(self):
     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):
     def save(self, *args, **kwargs):
+
+        # Save the flattened nodes list
+        self._nodes = list(itertools.chain(*self.path))
+
         super().save(*args, **kwargs)
         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
     @property
     def segment_count(self):
     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
     @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
         from circuits.models import CircuitTermination
 
 
-        if origin is None or origin.link is None:
-            return None
-
-        destination = None
         path = []
         path = []
         position_stack = []
         position_stack = []
+        is_complete = False
         is_active = True
         is_active = True
         is_split = False
         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
                 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:
                 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:
                 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
                     is_split = True
                     break
                     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
                     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
                     break
 
 
+                terminations = [circuit_termination]
+
             # Anything else marks the end of the path
             # Anything else marks the end of the path
             else:
             else:
-                destination = peer_termination
+                is_complete = True
                 break
                 break
 
 
-        if destination is None:
-            is_active = False
-
         return cls(
         return cls(
-            origin=origin,
-            destination=destination,
             path=path,
             path=path,
+            is_complete=is_complete,
             is_active=is_active,
             is_active=is_active,
             is_split=is_split
             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.
         Return the path as a list of prefetched objects.
         """
         """
         # Compile a list of IDs to prefetch for each type of model in the path
         # Compile a list of IDs to prefetch for each type of model in the path
         to_prefetch = defaultdict(list)
         to_prefetch = defaultdict(list)
-        for node in self.path:
+        for node in self._nodes:
             ct_id, object_id = decompile_path_node(node)
             ct_id, object_id = decompile_path_node(node)
             to_prefetch[ct_id].append(object_id)
             to_prefetch[ct_id].append(object_id)
 
 
@@ -484,19 +614,15 @@ class CablePath(models.Model):
 
 
         # Replicate the path using the prefetched objects.
         # Replicate the path using the prefetched objects.
         path = []
         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
         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):
     def get_cable_ids(self):
         """
         """
         Return all Cable IDs within the path.
         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_ct = ContentType.objects.get_for_model(Cable).pk
         cable_ids = []
         cable_ids = []
 
 
-        for node in self.path:
+        for node in self._nodes:
             ct, id = decompile_path_node(node)
             ct, id = decompile_path_node(node)
             if ct == cable_ct:
             if ct == cable_ct:
                 cable_ids.append(id)
                 cable_ids.append(id)
@@ -527,6 +653,6 @@ class CablePath(models.Model):
         """
         """
         Return all available next segments in a split cable path.
         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)
         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.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Sum
 from django.db.models import Sum
@@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField, WWNField
 from dcim.fields import MACAddressField, WWNField
-from dcim.svg import CableTraceSVG
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr
 
 
 __all__ = (
 __all__ = (
     'BaseInterface',
     'BaseInterface',
-    'LinkTermination',
+    'CabledObjectModel',
     'ConsolePort',
     'ConsolePort',
     'ConsoleServerPort',
     'ConsoleServerPort',
     'DeviceBay',
     'DeviceBay',
@@ -103,14 +104,10 @@ class ModularComponentModel(ComponentModel):
         abstract = True
         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(
     cable = models.ForeignKey(
         to='dcim.Cable',
         to='dcim.Cable',
@@ -119,36 +116,21 @@ class LinkTermination(models.Model):
         blank=True,
         blank=True,
         null=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,
         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(
     mark_connected = models.BooleanField(
         default=False,
         default=False,
         help_text="Treat as if a cable is connected"
         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:
     class Meta:
@@ -157,13 +139,32 @@ class LinkTermination(models.Model):
     def clean(self):
     def clean(self):
         super().clean()
         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({
             raise ValidationError({
                 "mark_connected": "Cannot mark as connected with a cable attached."
                 "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
     @property
     def _occupied(self):
     def _occupied(self):
@@ -171,19 +172,18 @@ class LinkTermination(models.Model):
 
 
     @property
     @property
     def parent_object(self):
     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
     @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):
 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.
     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
     `_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
         origin = self
         path = []
         path = []
 
 
-        # Construct the complete path
+        # Construct the complete path (including e.g. bridged interfaces)
         while origin is not None:
         while origin is not None:
 
 
             if origin._path is None:
             if origin._path is None:
                 break
                 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)
                 # 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))
         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)
         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
 # Console components
 #
 #
 
 
-class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
+class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
     """
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     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})
         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.
     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
 # 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.
     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)."
                     '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):
     def get_power_draw(self):
         """
         """
         Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
         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
         # 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:
         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'),
                 maximum_draw_total=Sum('maximum_draw'),
                 allocated_draw_total=Sum('allocated_draw'),
                 allocated_draw_total=Sum('allocated_draw'),
             )
             )
             ret = {
             ret = {
                 'allocated': utilization['allocated_draw_total'] or 0,
                 'allocated': utilization['allocated_draw_total'] or 0,
                 'maximum': utilization['maximum_draw_total'] or 0,
                 'maximum': utilization['maximum_draw_total'] or 0,
-                'outlet_count': len(outlet_ids),
+                'outlet_count': self.poweroutlets.count(),
                 'legs': [],
                 '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:
                 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'),
                         maximum_draw_total=Sum('maximum_draw'),
                         allocated_draw_total=Sum('allocated_draw'),
                         allocated_draw_total=Sum('allocated_draw'),
                     )
                     )
@@ -385,7 +397,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
                         'name': leg_name,
                         'name': leg_name,
                         'allocated': utilization['allocated_draw_total'] or 0,
                         'allocated': utilization['allocated_draw_total'] or 0,
                         'maximum': utilization['maximum_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
             return ret
@@ -394,12 +406,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
         return {
         return {
             'allocated': self.allocated_draw or 0,
             'allocated': self.allocated_draw or 0,
             'maximum': self.maximum_draw or 0,
             'maximum': self.maximum_draw or 0,
-            'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
+            'outlet_count': self.poweroutlets.count(),
             'legs': [],
             '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.
     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
         # Validate power port assignment
         if self.power_port and self.power_port.device != self.device:
         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()
         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.
     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):
     def link(self):
         return self.cable or self.wireless_link
         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
     @property
     def l2vpn_termination(self):
     def l2vpn_termination(self):
         return self.l2vpn_terminations.first()
         return self.l2vpn_terminations.first()
@@ -838,7 +860,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
 # Pass-through ports
 # Pass-through ports
 #
 #
 
 
-class FrontPort(ModularComponentModel, LinkTermination):
+class FrontPort(ModularComponentModel, CabledObjectModel):
     """
     """
     A pass-through port on the front of a Device.
     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.
     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.config import ConfigItem
 from netbox.models import NetBoxModel
 from netbox.models import NetBoxModel
 from utilities.validators import ExclusionValidator
 from utilities.validators import ExclusionValidator
-from .device_components import LinkTermination, PathEndpoint
+from .device_components import CabledObjectModel, PathEndpoint
 
 
 __all__ = (
 __all__ = (
     'PowerFeed',
     '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.
     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:
         if not available_power_total:
             return 0
             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):
 class RackReservation(NetBoxModel):

+ 37 - 55
netbox/dcim/signals.py

@@ -1,11 +1,11 @@
 import logging
 import logging
 
 
-from django.contrib.contenttypes.models import ContentType
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.dispatch import receiver
 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
 from .utils import create_cablepath, rebuild_paths
 
 
 
 
@@ -68,73 +68,55 @@ def clear_virtualchassis_members(instance, **kwargs):
 # Cables
 # Cables
 #
 #
 
 
-
-@receiver(post_save, sender=Cable)
+@receiver(trace_paths, sender=Cable)
 def update_connected_endpoints(instance, created, raw=False, **kwargs):
 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')
     logger = logging.getLogger('netbox.dcim.cable')
     if raw:
     if raw:
         logger.debug(f"Skipping endpoint updates for imported cable {instance}")
         logger.debug(f"Skipping endpoint updates for imported cable {instance}")
         return
         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:
             else:
-                rebuild_paths(termination)
+                rebuild_paths(nodes)
+
+    # Update status of CablePaths if Cable status has been changed
     elif instance.status != instance._orig_status:
     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:
         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:
         else:
-            rebuild_paths(instance)
+            rebuild_paths([instance])
 
 
 
 
 @receiver(post_delete, sender=Cable)
 @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
 import svgwrite
-
-from django.conf import settings
 from svgwrite.container import Group, Hyperlink
 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 svgwrite.text import Text
 
 
+from django.conf import settings
+
+from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from utilities.utils import foreground_color
 from utilities.utils import foreground_color
 
 
+
 __all__ = (
 __all__ = (
     'CableTraceSVG',
     'CableTraceSVG',
 )
 )
@@ -15,6 +17,95 @@ __all__ = (
 OFFSET = 0.5
 OFFSET = 0.5
 PADDING = 10
 PADDING = 10
 LINE_HEIGHT = 20
 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:
 class CableTraceSVG:
@@ -25,7 +116,7 @@ class CableTraceSVG:
     :param width: Width of the generated image (in pixels)
     :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.
     :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.origin = origin
         self.width = width
         self.width = width
         self.base_url = base_url.rstrip('/') if base_url is not None else ''
         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
         # Center edges on pixels to render sharp borders
         self.cursor = OFFSET
         self.cursor = OFFSET
 
 
+        # Prep elements lists
+        self.parent_objects = []
+        self.terminations = []
+        self.connectors = []
+
     @property
     @property
     def center(self):
     def center(self):
         return self.width / 2
         return self.width / 2
@@ -78,95 +174,103 @@ class CableTraceSVG:
             # Other parent object
             # Other parent object
             return 'e0e0e0'
             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.
         Draw a line with labels representing a WirelessLink.
-
-        :param url: Hyperlink URL
-        :param labels: Iterable of text labels
         """
         """
         group = Group(class_='connector')
         group = Group(class_='connector')
 
 
+        labels = [
+            f'Wireless link {wirelesslink}',
+            wirelesslink.get_status_display()
+        ]
+        if wirelesslink.ssid:
+            labels.append(wirelesslink.ssid)
+
         # Draw the wireless link
         # Draw the wireless link
         start = (OFFSET + self.center, self.cursor)
         start = (OFFSET + self.center, self.cursor)
         height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
         height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
@@ -177,7 +281,7 @@ class CableTraceSVG:
         self.cursor += PADDING * 2
         self.cursor += PADDING * 2
 
 
         # Add link
         # 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)
         # Add text label(s)
         for i, label in enumerate(labels):
         for i, label in enumerate(labels):
@@ -191,7 +295,7 @@ class CableTraceSVG:
 
 
         return group
         return group
 
 
-    def _draw_attachment(self):
+    def draw_attachment(self):
         """
         """
         Return an SVG group containing a line element and "Attachment" label.
         Return an SVG group containing a line element and "Attachment" label.
         """
         """
@@ -216,109 +320,63 @@ class CableTraceSVG:
 
 
         traced_path = self.origin.trace()
         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):
         for i, segment in enumerate(traced_path):
-            near_end, connector, far_end = segment
+            near_ends, links, far_ends = segment
 
 
             # Near end parent
             # Near end parent
             if i == 0:
             if i == 0:
                 # If this is the first segment, draw the originating termination's parent object
                 # 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)
             # Connector (a Cable or WirelessLink)
-            if connector is not None:
+            if links:
+                link = links[0]  # Remove Cable from list
 
 
                 # Cable
                 # 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
                 # 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
                 # 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
-                attachment = self._draw_attachment()
-                connectors.append(attachment)
+                attachment = self.draw_attachment()
+                self.connectors.append(attachment)
 
 
                 # ProviderNetwork
                 # 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
         # Determine drawing size
         self.drawing = svgwrite.Drawing(
         self.drawing = svgwrite.Drawing(
@@ -330,7 +388,7 @@ class CableTraceSVG:
             self.drawing.defs.add(self.drawing.style(css_file.read()))
             self.drawing.defs.add(self.drawing.style(css_file.read()))
 
 
         # Add elements to the drawing in order of depth (Z axis)
         # 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)
             self.drawing.add(element)
 
 
         return self.drawing
         return self.drawing

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

@@ -1,56 +1,109 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
+from django.utils.safestring import mark_safe
 
 
 from dcim.models import Cable
 from dcim.models import Cable
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
-from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
+from .template_code import CABLE_LENGTH
 
 
 __all__ = (
 __all__ = (
     'CableTable',
     '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
 # Cables
 #
 #
 
 
 class CableTable(NetBoxTable):
 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,
         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,
         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,
         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,
         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,
         orderable=False,
-        linkify=True,
         verbose_name='Rack B'
         verbose_name='Rack B'
     )
     )
-    termination_b = tables.Column(
-        accessor=Accessor('termination_b'),
+    site_a = CableTerminationsColumn(
+        cable_end='A',
+        attr='_site',
         orderable=False,
         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()
     status = columns.ChoiceFieldColumn()
     tenant = TenantColumn()
     tenant = TenantColumn()
@@ -66,10 +119,10 @@ class CableTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Cable
         model = Cable
         fields = (
         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 = (
         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 %}
 {% 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 = """
 DEVICE_LINK = """
 <a href="{% url 'dcim:device' pk=record.pk %}">
 <a href="{% url 'dcim:device' pk=record.pk %}">
@@ -133,9 +135,9 @@ CONSOLEPORT_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
         <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>
         </ul>
     </span>
     </span>
 {% else %}
 {% else %}
@@ -165,9 +167,9 @@ CONSOLESERVERPORT_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
         <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>
         </ul>
     </span>
     </span>
 {% else %}
 {% else %}
@@ -197,8 +199,8 @@ POWERPORT_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
         <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>
         </ul>
     </span>
     </span>
 {% else %}
 {% 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-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>
     <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 %}
     {% 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>
             <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
         </a>
         </a>
     {% else %}
     {% else %}
@@ -274,10 +276,10 @@ INTERFACE_BUTTONS = """
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
         </button>
         </button>
         <ul class="dropdown-menu dropdown-menu-end">
         <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>
         </ul>
     </span>
     </span>
     {% else %}
     {% else %}
@@ -313,12 +315,12 @@ FRONTPORT_BUTTONS = """
                 <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
                 <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             </button>
             </button>
             <ul class="dropdown-menu dropdown-menu-end">
             <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>
             </ul>
         </span>
         </span>
     {% else %}
     {% else %}
@@ -350,12 +352,12 @@ REARPORT_BUTTONS = """
                 <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
                 <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
             </button>
             </button>
             <ul class="dropdown-menu dropdown-menu-end">
             <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>
             </ul>
         </span>
         </span>
     {% else %}
     {% else %}

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

@@ -45,7 +45,7 @@ class Mixins:
                 device=peer_device,
                 device=peer_device,
                 name='Peer Termination'
                 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()
             cable.save()
 
 
             self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
             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.assertHttpStatus(response, status.HTTP_200_OK)
             self.assertEqual(len(response.data), 1)
             self.assertEqual(len(response.data), 1)
             segment1 = response.data[0]
             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[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):
 class RegionTest(APIViewTestCases.APIViewTestCase):
@@ -1884,33 +1884,33 @@ class CableTest(APIViewTestCases.APIViewTestCase):
         Interface.objects.bulk_create(interfaces)
         Interface.objects.bulk_create(interfaces)
 
 
         cables = (
         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:
         for cable in cables:
             cable.save()
             cable.save()
 
 
         cls.create_data = [
         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',
                 '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',
                 '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',
                 'label': 'Cable 6',
             },
             },
         ]
         ]
@@ -1936,7 +1936,7 @@ class ConnectedDeviceTest(APITestCase):
         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
         self.interface3 = Interface.objects.create(device=self.device1, name='eth1')  # Not connected
         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()
         cable.save()
 
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @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)
         ConsolePort.objects.bulk_create(console_ports)
 
 
         # Cables
         # 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
         # Third port is not connected
 
 
     def test_name(self):
     def test_name(self):
@@ -2097,8 +2097,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         ConsoleServerPort.objects.bulk_create(console_server_ports)
         ConsoleServerPort.objects.bulk_create(console_server_ports)
 
 
         # Cables
         # 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
         # Third port is not connected
 
 
     def test_name(self):
     def test_name(self):
@@ -2244,8 +2244,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         PowerPort.objects.bulk_create(power_ports)
         PowerPort.objects.bulk_create(power_ports)
 
 
         # Cables
         # 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
         # Third port is not connected
 
 
     def test_name(self):
     def test_name(self):
@@ -2399,8 +2399,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         PowerOutlet.objects.bulk_create(power_outlets)
         PowerOutlet.objects.bulk_create(power_outlets)
 
 
         # Cables
         # 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
         # Third port is not connected
 
 
     def test_name(self):
     def test_name(self):
@@ -2656,8 +2656,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         Interface.objects.bulk_create(interfaces)
         Interface.objects.bulk_create(interfaces)
 
 
         # Cables
         # 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
         # Third pair is not connected
 
 
     def test_name(self):
     def test_name(self):
@@ -2932,8 +2932,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
 
 
         # Cables
         # 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
         # Third port is not connected
 
 
     def test_name(self):
     def test_name(self):
@@ -3078,8 +3078,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
 
 
         # Cables
         # 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
         # Third port is not connected
 
 
     def test_name(self):
     def test_name(self):
@@ -3663,6 +3663,21 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         Site.objects.bulk_create(sites)
         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 = (
         tenants = (
             Tenant(name='Tenant 1', slug='tenant-1'),
             Tenant(name='Tenant 1', slug='tenant-1'),
             Tenant(name='Tenant 2', slug='tenant-2'),
             Tenant(name='Tenant 2', slug='tenant-2'),
@@ -3670,24 +3685,17 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         Tenant.objects.bulk_create(tenants)
         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')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-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')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
 
         devices = (
         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)
         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')
         console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
 
 
         # Cables
         # 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):
     def test_label(self):
         params = {'label': ['Cable 1', 'Cable 2']}
         params = {'label': ['Cable 1', 'Cable 2']}
@@ -3759,6 +3767,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'rack': [racks[0].name, racks[1].name]}
         params = {'rack': [racks[0].name, racks[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         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):
     def test_site(self):
         site = Site.objects.all()[:2]
         site = Site.objects.all()[:2]
         params = {'site_id': [site[0].pk, site[1].pk]}
         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)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_termination_ids(self):
     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 = {
         params = {
             'termination_a_type': 'dcim.interface',
             'termination_a_type': 'dcim.interface',
             'termination_a_id': list(interface_ids),
             'termination_a_id': list(interface_ids),
@@ -3924,8 +3942,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
             PowerPort(device=device, name='Power Port 2'),
             PowerPort(device=device, name='Power Port 2'),
         ]
         ]
         PowerPort.objects.bulk_create(power_ports)
         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):
     def test_name(self):
         params = {'name': ['Power Feed 1', 'Power Feed 2']}
         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.interface1 = Interface.objects.create(device=self.device1, name='eth0')
         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
         self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
         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.cable.save()
 
 
         self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
         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.
         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):
     def test_cable_deletion(self):
         """
         """
@@ -510,50 +512,33 @@ class CableTestCase(TestCase):
         self.assertNotEqual(str(self.cable), '#None')
         self.assertNotEqual(str(self.cable), '#None')
         interface1 = Interface.objects.get(pk=self.interface1.pk)
         interface1 = Interface.objects.get(pk=self.interface1.pk)
         self.assertIsNone(interface1.cable)
         self.assertIsNone(interface1.cable)
-        self.assertIsNone(interface1._link_peer)
+        self.assertListEqual(interface1.link_peers, [])
         interface2 = Interface.objects.get(pk=self.interface2.pk)
         interface2 = Interface.objects.get(pk=self.interface2.pk)
         self.assertIsNone(interface2.cable)
         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):
         with self.assertRaises(ValidationError):
             cable.clean()
             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):
         with self.assertRaises(ValidationError):
             cable.clean()
             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):
         with self.assertRaises(ValidationError):
             cable.clean()
             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
         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):
         with self.assertRaises(ValidationError):
             cable.clean()
             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):
     def test_cable_cannot_terminate_to_a_virtual_interface(self):
         """
         """
         A cable cannot terminate to a virtual interface
         A cable cannot terminate to a virtual interface
         """
         """
         virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
         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):
         with self.assertRaises(ValidationError):
             cable.clean()
             cable.clean()
 
 
@@ -608,6 +564,6 @@ class CableTestCase(TestCase):
         A cable cannot terminate to a wireless interface
         A cable cannot terminate to a wireless interface
         """
         """
         wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
         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):
         with self.assertRaises(ValidationError):
             cable.clean()
             cable.clean()

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

@@ -1961,7 +1961,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=consoleport.device,
             device=consoleport.device,
             name='Console Server Port 1'
             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}))
         response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
@@ -2017,7 +2017,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=consoleserverport.device,
             device=consoleserverport.device,
             name='Console Port 1'
             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}))
         response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
@@ -2079,7 +2079,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=powerport.device,
             device=powerport.device,
             name='Power Outlet 1'
             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}))
         response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
@@ -2144,7 +2144,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     def test_trace(self):
     def test_trace(self):
         poweroutlet = PowerOutlet.objects.first()
         poweroutlet = PowerOutlet.objects.first()
         powerport = PowerPort.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}))
         response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
@@ -2268,7 +2268,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
         interface1, interface2 = Interface.objects.all()[:2]
         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}))
         response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
@@ -2339,7 +2339,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=frontport.device,
             device=frontport.device,
             name='Interface 1'
             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}))
         response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
@@ -2397,7 +2397,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             device=rearport.device,
             device=rearport.device,
             name='Interface 1'
             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}))
         response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk}))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
@@ -2630,19 +2630,18 @@ class CableTestCase(
         )
         )
         Interface.objects.bulk_create(interfaces)
         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')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         interface_ct = ContentType.objects.get_for_model(Interface)
         interface_ct = ContentType.objects.get_for_model(Interface)
         cls.form_data = {
         cls.form_data = {
+            # TODO: Revisit this limitation
             # Changing terminations not supported when editing an existing Cable
             # 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,
             'type': CableTypeChoices.TYPE_CAT6,
             'status': LinkStatusChoices.STATUS_PLANNED,
             'status': LinkStatusChoices.STATUS_PLANNED,
             'label': 'Label',
             'label': 'Label',
@@ -2864,7 +2863,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             device=device,
             device=device,
             name='Power Port 1'
             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}))
         response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
         self.assertHttpStatus(response, 200)
         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>/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>/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: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'),
     path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
 
 
     # Console server ports
     # 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>/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>/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: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'),
     path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
 
 
     # Power ports
     # 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>/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>/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: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'),
     path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
 
 
     # Power outlets
     # 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>/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>/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: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'),
     path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
 
 
     # Interfaces
     # Interfaces
@@ -358,7 +354,6 @@ urlpatterns = [
     path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     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>/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: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'),
     path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
 
 
     # Front ports
     # 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>/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>/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: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'),
     # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
 
 
     # Rear ports
     # 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>/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>/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: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'),
     path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
 
 
     # Module bays
     # Module bays
@@ -447,6 +440,7 @@ urlpatterns = [
 
 
     # Cables
     # Cables
     path('cables/', views.CableListView.as_view(), name='cable_list'),
     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/import/', views.CableBulkImportView.as_view(), name='cable_import'),
     path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
     path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
     path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
     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>/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>/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: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.contrib.contenttypes.models import ContentType
 from django.db import transaction
 from django.db import transaction
 
 
@@ -29,27 +31,29 @@ def path_node_to_object(repr):
     return ct.model_class().objects.get(pk=object_id)
     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
     from dcim.models import CablePath
 
 
-    cp = CablePath.from_origin(node)
+    cp = CablePath.from_origin(terminations)
     if cp:
     if cp:
         cp.save()
         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
     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.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 
 
-from circuits.models import Circuit
+from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
 from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
@@ -28,6 +28,18 @@ from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import *
 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):
 class DeviceComponentsView(generic.ObjectChildrenView):
     queryset = Device.objects.all()
     queryset = Device.objects.all()
@@ -1717,7 +1729,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
         interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
-            '_path__destination'
+            '_path'
         ).exclude(
         ).exclude(
             type__in=NONCONNECTABLE_IFACE_TYPES
             type__in=NONCONNECTABLE_IFACE_TYPES
         )
         )
@@ -2744,7 +2756,10 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView):
 #
 #
 
 
 class CableListView(generic.ObjectListView):
 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 = filtersets.CableFilterSet
     filterset_form = forms.CableFilterForm
     filterset_form = forms.CableFilterForm
     table = tables.CableTable
     table = tables.CableTable
@@ -2777,7 +2792,7 @@ class PathTraceView(generic.ObjectView):
 
 
         # Otherwise, find all CablePaths which traverse the specified object
         # Otherwise, find all CablePaths which traverse the specified object
         else:
         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)
             # Check for specification of a particular path (when tracing pass-through ports)
             try:
             try:
                 path_id = int(request.GET.get('cablepath_id'))
                 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)
         total_length, is_definitive = path.get_total_length() if path else (None, False)
 
 
         # Determine the path to the SVG trace image
         # 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 {
         return {
             'path': path,
             'path': path,
@@ -2810,77 +2825,38 @@ class PathTraceView(generic.ObjectView):
         }
         }
 
 
 
 
-class CableCreateView(generic.ObjectEditView):
+class CableEditView(generic.ObjectEditView):
     queryset = Cable.objects.all()
     queryset = Cable.objects.all()
-    template_name = 'dcim/cable_connect.html'
+    template_name = 'dcim/cable_edit.html'
 
 
     def dispatch(self, request, *args, **kwargs):
     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)
         return super().dispatch(request, *args, **kwargs)
 
 
     def get_object(self, **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
         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):
 class CableDeleteView(generic.ObjectDeleteView):
     queryset = Cable.objects.all()
     queryset = Cable.objects.all()
@@ -2893,14 +2869,14 @@ class CableBulkImportView(generic.BulkImportView):
 
 
 
 
 class CableBulkEditView(generic.BulkEditView):
 class CableBulkEditView(generic.BulkEditView):
-    queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
+    queryset = Cable.objects.prefetch_related('terminations')
     filterset = filtersets.CableFilterSet
     filterset = filtersets.CableFilterSet
     table = tables.CableTable
     table = tables.CableTable
     form = forms.CableBulkEditForm
     form = forms.CableBulkEditForm
 
 
 
 
 class CableBulkDeleteView(generic.BulkDeleteView):
 class CableBulkDeleteView(generic.BulkDeleteView):
-    queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
+    queryset = Cable.objects.prefetch_related('terminations')
     filterset = filtersets.CableFilterSet
     filterset = filtersets.CableFilterSet
     table = tables.CableTable
     table = tables.CableTable
 
 

+ 2 - 2
netbox/netbox/middleware.py

@@ -179,8 +179,8 @@ class ExceptionHandlingMiddleware:
     def process_exception(self, request, exception):
     def process_exception(self, request, exception):
 
 
         # Handle exceptions that occur from REST API requests
         # 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
         # Don't catch exceptions when in debug mode
         if settings.DEBUG:
         if settings.DEBUG:

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

@@ -3,7 +3,6 @@ import sys
 
 
 from django.conf import settings
 from django.conf import settings
 from django.core.cache import cache
 from django.core.cache import cache
-from django.db.models import F
 from django.http import HttpResponseServerError
 from django.http import HttpResponseServerError
 from django.shortcuts import redirect, render
 from django.shortcuts import redirect, render
 from django.template import loader
 from django.template import loader
@@ -37,14 +36,13 @@ class HomeView(View):
             return redirect("login")
             return redirect("login")
 
 
         connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
         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(
         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(
         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():
         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 {
   line {
     stroke-width: 5px;
     stroke-width: 5px;
   }
   }
-  line.cable-shadow {
+  polyline {
+    fill: none;
+    stroke-width: 5px;
+  }
+  .cable-shadow {
     stroke: var(--nbx-trace-cable-shadow);
     stroke: var(--nbx-trace-cable-shadow);
     stroke-width: 7px;
     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-success"><i class="mdi mdi-check-bold"></i></span>
                   <span class="text-muted">Marked as connected</span>
                   <span class="text-muted">Marked as connected</span>
                 {% elif termination.cable %}
                 {% 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 %}
                     {% if peer.device %}
                       {{ peer.device|linkify }}<br/>
                       {{ peer.device|linkify }}<br/>
                     {% elif peer.circuit %}
                     {% elif peer.circuit %}
                       {{ peer.circuit|linkify }}<br/>
                       {{ peer.circuit|linkify }}<br/>
                     {% endif %}
                     {% endif %}
-                    {{ peer|linkify }}
-                  {% endwith %}
+                    {{ peer|linkify }}{% if not forloop.last %},{% endif %}
+                  {% endfor %}
                   <div class="mt-1">
                   <div class="mt-1">
                     <a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
                     <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
                       <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
                       <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
                     </button>
                     </button>
                     <ul class="dropdown-menu">
                     <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>
                     </ul>
                   </div>
                   </div>
                 {% endif %}
                 {% endif %}

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

@@ -5,85 +5,79 @@
 {% load plugins %}
 {% load plugins %}
 
 
 {% block content %}
 {% 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>
+      </div>
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
     </div>
     </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>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
     </div>
     </div>
+  </div>
 {% endblock %}
 {% 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 %}
 {% endblock %}

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

@@ -111,28 +111,13 @@
                                     </button>
                                     </button>
                                     <ul class="dropdown-menu dropdown-menu-end">
                                     <ul class="dropdown-menu dropdown-menu-end">
                                         <li>
                                         <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>
                                         <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>
                                         <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>
                                         </li>
                                     </ul>
                                     </ul>
                                 </div>
                                 </div>

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

@@ -113,28 +113,13 @@
                                 </button>
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
                                     <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>
                                     <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>
                                     <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>
                                     </li>
                                 </ul>
                                 </ul>
                             </div>
                             </div>

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

@@ -105,22 +105,22 @@
                                 </button>
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
                                     <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>
                                     <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>
                                     <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>
                                     <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>
                                     <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>
                                     <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>
                                     </li>
                                 </ul>
                                 </ul>
                             </div>
                             </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 %}
 {% load helpers %}
 <table class="table table-hover panel-body attr-table">
 <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>
 </table>

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

@@ -263,24 +263,16 @@
                     </button>
                     </button>
                     <ul class="dropdown-menu dropdown-menu-end">
                     <ul class="dropdown-menu dropdown-menu-end">
                       <li>
                       <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>
                       <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>
                       <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>
                       <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>
                       </li>
                     </ul>
                     </ul>
                   </div>
                   </div>

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

@@ -158,8 +158,7 @@
             {% if not object.mark_connected and not object.cable %}
             {% if not object.mark_connected and not object.cable %}
             <div class="card-footer">
             <div class="card-footer">
             {% if perms.dcim.add_cable %}
             {% 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
                     <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
                 </a>
                 </a>
                     {% endif %}
                     {% endif %}

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

@@ -111,7 +111,7 @@
                     <div class="text-muted">
                     <div class="text-muted">
                         Not Connected
                         Not Connected
                         {% if perms.dcim.add_cable %}
                         {% 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
                                 <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
                             </a>
                             </a>
                         {% endif %}
                         {% endif %}

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

@@ -117,10 +117,10 @@
                                 </button>
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
                                     <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>
                                     <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>
                                     </li>
                                 </ul>
                                 </ul>
                             </span>
                             </span>

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

@@ -101,16 +101,16 @@
                                 </button>
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
                                     <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>
                                     <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>
                                     <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>
                                     <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>
                                     </li>
                                 </ul>
                                 </ul>
                             </span>
                             </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:
     if instance.interface_a.wireless_link != instance:
         logger.debug(f"Updating interface A for wireless link {instance}")
         logger.debug(f"Updating interface A for wireless link {instance}")
         instance.interface_a.wireless_link = instance
         instance.interface_a.wireless_link = instance
-        instance.interface_a._link_peer = instance.interface_b
         instance.interface_a.save()
         instance.interface_a.save()
     if instance.interface_b.cable != instance:
     if instance.interface_b.cable != instance:
         logger.debug(f"Updating interface B for wireless link {instance}")
         logger.debug(f"Updating interface B for wireless link {instance}")
         instance.interface_b.wireless_link = instance
         instance.interface_b.wireless_link = instance
-        instance.interface_b._link_peer = instance.interface_a
         instance.interface_b.save()
         instance.interface_b.save()
 
 
     # Create/update cable paths
     # Create/update cable paths
     if created:
     if created:
         for interface in (instance.interface_a, instance.interface_b):
         for interface in (instance.interface_a, instance.interface_b):
-            create_cablepath(interface)
+            create_cablepath([interface])
 
 
 
 
 @receiver(post_delete, sender=WirelessLink)
 @receiver(post_delete, sender=WirelessLink)
@@ -48,19 +46,11 @@ def nullify_connected_interfaces(instance, **kwargs):
 
 
     if instance.interface_a is not None:
     if instance.interface_a is not None:
         logger.debug(f"Nullifying interface A for wireless link {instance}")
         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:
     if instance.interface_b is not None:
         logger.debug(f"Nullifying interface B for wireless link {instance}")
         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
     # 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()
         cablepath.delete()

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません