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

9604 Add Termination to CircuitTermination (#17821)

* 9604 add scope type to CircuitTermination

* 9604 add scope type to CircuitTermination

* 9604 add scope type to CircuitTermination

* 9604 model_forms

* 9604 form filtersets

* 9604 form bulk_import

* 9604 form bulk_edit

* 9604 serializers

* 9604 graphql

* 9604 tests and detail template

* 9604 fix migration merge

* 9604 fix tests

* 9604 fix tests

* 9604 fix table

* 9604 updates

* fix tests

* fix tests

* fix tests

* fix tests

* fix tests

* fix tests

* fix tests

* 9604 remove provider_network

* 9604 fix tests

* 9604 fix tests

* 9604 fix forms

* 9604 review changes

* 9604 scope->termination

* 9604 fix _circuit_terminations

* 9604 fix _circuit_terminations

* 9604 sitegroup -> site_group

* 9604 update docs

* 9604 fix form termination side reference

* Misc cleanup

* Fix terminations in circuits table

* Fix missing imports

* Clean up termination attrs display

* Add termination & type to CircuitTerminationTable

* Update cable tracing logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson 1 год назад
Родитель
Сommit
a8eb455f3e

+ 2 - 6
docs/models/circuits/circuittermination.md

@@ -21,13 +21,9 @@ Designates the termination as forming either the A or Z end of the circuit.
 
 If selected, the circuit termination will be considered "connected" even if no cable has been connected to it in NetBox.
 
-### Site
+### Termination
 
-The [site](../dcim/site.md) with which this circuit termination is associated. Once created, a cable can be connected between the circuit termination and a device interface (or similar component).
-
-### Provider Network
-
-Circuits which do not connect to a site modeled by NetBox can instead be terminated to a [provider network](./providernetwork.md) representing an unknown network operated by a [provider](./provider.md).
+The [region](../dcim/region.md), [site group](../dcim/sitegroup.md), [site](../dcim/site.md), [location](../dcim/location.md) or [provider network](./providernetwork.md) with which this circuit termination is associated. Once created, a cable can be connected between the circuit termination and a device interface (or similar component).
 
 ### Port Speed
 

+ 45 - 6
netbox/circuits/api/serializers_/circuits.py

@@ -1,11 +1,16 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
 from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices
+from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
 from dcim.api.serializers_.cables import CabledObjectSerializer
-from dcim.api.serializers_.sites import SiteSerializer
-from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from netbox.choices import DistanceUnitChoices
 from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
 
 from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
 
@@ -33,16 +38,33 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
 
 
 class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
-    site = SiteSerializer(nested=True, allow_null=True)
+    termination_type = ContentTypeField(
+        queryset=ContentType.objects.filter(
+            model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES
+        ),
+        allow_null=True,
+        required=False,
+        default=None
+    )
+    termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+    termination = serializers.SerializerMethodField(read_only=True)
     provider_network = ProviderNetworkSerializer(nested=True, allow_null=True)
 
     class Meta:
         model = CircuitTermination
         fields = [
-            'id', 'url', 'display_url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed',
+            'id', 'url', 'display_url', 'display', 'termination_type', 'termination_id', 'termination', 'provider_network', 'port_speed', 'upstream_speed',
             'xconnect_id', 'description',
         ]
 
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_termination(self, obj):
+        if obj.termination_id is None:
+            return None
+        serializer = get_serializer_for_model(obj.termination)
+        context = {'request': self.context['request']}
+        return serializer(obj.termination, nested=True, context=context).data
+
 
 class CircuitGroupSerializer(NetBoxModelSerializer):
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
@@ -95,18 +117,35 @@ class CircuitSerializer(NetBoxModelSerializer):
 
 class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
     circuit = CircuitSerializer(nested=True)
-    site = SiteSerializer(nested=True, required=False, allow_null=True)
+    termination_type = ContentTypeField(
+        queryset=ContentType.objects.filter(
+            model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES
+        ),
+        allow_null=True,
+        required=False,
+        default=None
+    )
+    termination_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+    termination = serializers.SerializerMethodField(read_only=True)
     provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True)
 
     class Meta:
         model = CircuitTermination
         fields = [
-            'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed',
+            'id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'termination_type', 'termination_id', 'termination', 'provider_network', 'port_speed',
             'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end',
             'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
 
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_termination(self, obj):
+        if obj.termination_id is None:
+            return None
+        serializer = get_serializer_for_model(obj.termination)
+        context = {'request': self.context['request']}
+        return serializer(obj.termination, nested=True, context=context).data
+
 
 class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
     circuit = CircuitSerializer(nested=True)

+ 4 - 0
netbox/circuits/constants.py

@@ -0,0 +1,4 @@
+# models values for ContentTypes which may be CircuitTermination termination types
+CIRCUIT_TERMINATION_TERMINATION_TYPES = (
+    'region', 'sitegroup', 'site', 'location', 'providernetwork',
+)

+ 59 - 17
netbox/circuits/filtersets.py

@@ -3,11 +3,11 @@ from django.db.models import Q
 from django.utils.translation import gettext as _
 
 from dcim.filtersets import CabledObjectFilterSet
-from dcim.models import Region, Site, SiteGroup
+from dcim.models import Location, Region, Site, SiteGroup
 from ipam.models import ASN
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
-from utilities.filters import TreeNodeMultipleChoiceFilter
+from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
 from .choices import *
 from .models import *
 
@@ -26,37 +26,37 @@ __all__ = (
 class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
-        field_name='circuits__terminations__site__region',
+        field_name='circuits__terminations___region',
         lookup_expr='in',
         label=_('Region (ID)'),
     )
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
-        field_name='circuits__terminations__site__region',
+        field_name='circuits__terminations___region',
         lookup_expr='in',
         to_field_name='slug',
         label=_('Region (slug)'),
     )
     site_group_id = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
-        field_name='circuits__terminations__site__group',
+        field_name='circuits__terminations___site_group',
         lookup_expr='in',
         label=_('Site group (ID)'),
     )
     site_group = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
-        field_name='circuits__terminations__site__group',
+        field_name='circuits__terminations___site_group',
         lookup_expr='in',
         to_field_name='slug',
         label=_('Site group (slug)'),
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='circuits__terminations__site',
+        field_name='circuits__terminations___site',
         queryset=Site.objects.all(),
         label=_('Site'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='circuits__terminations__site__slug',
+        field_name='circuits__terminations___site__slug',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label=_('Site (slug)'),
@@ -173,7 +173,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         label=_('Provider account (account)'),
     )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__provider_network',
+        field_name='terminations___provider_network',
         queryset=ProviderNetwork.objects.all(),
         label=_('Provider network (ID)'),
     )
@@ -193,37 +193,37 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
     )
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
-        field_name='terminations__site__region',
+        field_name='terminations___region',
         lookup_expr='in',
         label=_('Region (ID)'),
     )
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
-        field_name='terminations__site__region',
+        field_name='terminations___region',
         lookup_expr='in',
         to_field_name='slug',
         label=_('Region (slug)'),
     )
     site_group_id = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
-        field_name='terminations__site__group',
+        field_name='terminations___site_group',
         lookup_expr='in',
         label=_('Site group (ID)'),
     )
     site_group = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
-        field_name='terminations__site__group',
+        field_name='terminations___site_group',
         lookup_expr='in',
         to_field_name='slug',
         label=_('Site group (slug)'),
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__site',
+        field_name='terminations___site',
         queryset=Site.objects.all(),
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__site__slug',
+        field_name='terminations___site__slug',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label=_('Site (slug)'),
@@ -263,18 +263,60 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
         queryset=Circuit.objects.all(),
         label=_('Circuit'),
     )
+    termination_type = ContentTypeFilter()
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='_region',
+        lookup_expr='in',
+        label=_('Region (ID)'),
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='_region',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Region (slug)'),
+    )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='_site_group',
+        lookup_expr='in',
+        label=_('Site group (ID)'),
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='_site_group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Site group (slug)'),
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
+        field_name='_site',
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='site__slug',
+        field_name='_site__slug',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label=_('Site (slug)'),
     )
+    location_id = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='_location',
+        lookup_expr='in',
+        label=_('Location (ID)'),
+    )
+    location = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='_location',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Location (slug)'),
+    )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ProviderNetwork.objects.all(),
+        field_name='_provider_network',
         label=_('ProviderNetwork (ID)'),
     )
     provider_id = django_filters.ModelMultipleChoiceFilter(
@@ -292,7 +334,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     class Meta:
         model = CircuitTermination
         fields = (
-            'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected',
+            'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected',
             'pp_info', 'cable_end',
         )
 

+ 37 - 17
netbox/circuits/forms/bulk_edit.py

@@ -1,17 +1,23 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
+from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import *
 from dcim.models import Site
 from ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
-from utilities.forms import add_blank_choice
-from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
-from utilities.forms.rendering import FieldSet, TabbedGroups
-from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
+from utilities.forms import add_blank_choice, get_field_value
+from utilities.forms.fields import (
+    ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+)
+from utilities.forms.rendering import FieldSet
+from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, HTMXSelect, NumberWithOptions
+from utilities.templatetags.builtins.filters import bettertitle
 
 __all__ = (
     'CircuitBulkEditForm',
@@ -197,15 +203,18 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         required=False
     )
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
-        required=False
+    termination_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
+        widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
+        required=False,
+        label=_('Termination type')
     )
-    provider_network = DynamicModelChoiceField(
-        label=_('Provider Network'),
-        queryset=ProviderNetwork.objects.all(),
-        required=False
+    termination = DynamicModelChoiceField(
+        label=_('Termination'),
+        queryset=Site.objects.none(),  # Initial queryset
+        required=False,
+        disabled=True,
+        selector=True
     )
     port_speed = forms.IntegerField(
         required=False,
@@ -225,15 +234,26 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
     fieldsets = (
         FieldSet(
             'description',
-            TabbedGroups(
-                FieldSet('site', name=_('Site')),
-                FieldSet('provider_network', name=_('Provider Network')),
-            ),
+            'termination_type', 'termination',
             'mark_connected', name=_('Circuit Termination')
         ),
         FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
     )
-    nullable_fields = ('description')
+    nullable_fields = ('description', 'termination')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if termination_type_id := get_field_value(self, 'termination_type'):
+            try:
+                termination_type = ContentType.objects.get(pk=termination_type_id)
+                model = termination_type.model_class()
+                self.fields['termination'].queryset = model.objects.all()
+                self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
+                self.fields['termination'].disabled = False
+                self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
+            except ObjectDoesNotExist:
+                pass
 
 
 class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):

+ 15 - 15
netbox/circuits/forms/bulk_import.py

@@ -1,13 +1,14 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import *
+from circuits.constants import *
 from circuits.models import *
-from dcim.models import Site
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
-from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 
 __all__ = (
     'CircuitImportForm',
@@ -127,17 +128,10 @@ class BaseCircuitTerminationImportForm(forms.ModelForm):
         label=_('Termination'),
         choices=CircuitTerminationSideChoices,
     )
-    site = CSVModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
-        to_field_name='name',
-        required=False
-    )
-    provider_network = CSVModelChoiceField(
-        label=_('Provider network'),
-        queryset=ProviderNetwork.objects.all(),
-        to_field_name='name',
-        required=False
+    termination_type = CSVContentTypeField(
+        queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
+        required=False,
+        label=_('Termination type (app & model)')
     )
 
 
@@ -145,9 +139,12 @@ class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
     class Meta:
         model = CircuitTermination
         fields = [
-            'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
+            'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id',
             'pp_info', 'description'
         ]
+        labels = {
+            'termination_id': _('Termination ID'),
+        }
 
 
 class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
@@ -155,9 +152,12 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination
     class Meta:
         model = CircuitTermination
         fields = [
-            'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
+            'circuit', 'term_side', 'termination_type', 'termination_id', 'port_speed', 'upstream_speed', 'xconnect_id',
             'pp_info', 'description', 'tags'
         ]
+        labels = {
+            'termination_id': _('Termination ID'),
+        }
 
 
 class CircuitGroupImportForm(NetBoxModelImportForm):

+ 18 - 7
netbox/circuits/forms/filtersets.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
 
 from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices
 from circuits.models import *
-from dcim.models import Region, Site, SiteGroup
+from dcim.models import Location, Region, Site, SiteGroup
 from ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelFilterSetForm
@@ -207,18 +207,29 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('circuit_id', 'term_side', name=_('Circuit')),
-        FieldSet('provider_id', 'provider_network_id', name=_('Provider')),
-        FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
+        FieldSet('provider_id', name=_('Provider')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Termination')),
+    )
+    region_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Region')
+    )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        query_params={
-            'region_id': '$region_id',
-            'site_group_id': '$site_group_id',
-        },
         label=_('Site')
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location')
+    )
     circuit_id = DynamicModelMultipleChoiceField(
         queryset=Circuit.objects.all(),
         required=False,

+ 48 - 15
netbox/circuits/forms/model_forms.py

@@ -1,14 +1,19 @@
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
+from circuits.constants import *
 from circuits.models import *
 from dcim.models import Site
 from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
-from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
-from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
-from utilities.forms.widgets import DatePicker, NumberWithOptions
+from utilities.forms import get_field_value
+from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from utilities.forms.rendering import FieldSet, InlineFields
+from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
+from utilities.templatetags.builtins.filters import bettertitle
 
 __all__ = (
     'CircuitForm',
@@ -144,26 +149,24 @@ class CircuitTerminationForm(NetBoxModelForm):
         queryset=Circuit.objects.all(),
         selector=True
     )
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
+    termination_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
+        widget=HTMXSelect(),
         required=False,
-        selector=True
+        label=_('Termination type')
     )
-    provider_network = DynamicModelChoiceField(
-        label=_('Provider network'),
-        queryset=ProviderNetwork.objects.all(),
+    termination = DynamicModelChoiceField(
+        label=_('Termination'),
+        queryset=Site.objects.none(),  # Initial queryset
         required=False,
+        disabled=True,
         selector=True
     )
 
     fieldsets = (
         FieldSet(
             'circuit', 'term_side', 'description', 'tags',
-            TabbedGroups(
-                FieldSet('site', name=_('Site')),
-                FieldSet('provider_network', name=_('Provider Network')),
-            ),
+            'termination_type', 'termination',
             'mark_connected', name=_('Circuit Termination')
         ),
         FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
@@ -172,7 +175,7 @@ class CircuitTerminationForm(NetBoxModelForm):
     class Meta:
         model = CircuitTermination
         fields = [
-            'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed', 'upstream_speed',
+            'circuit', 'term_side', 'termination_type', 'mark_connected', 'port_speed', 'upstream_speed',
             'xconnect_id', 'pp_info', 'description', 'tags',
         ]
         widgets = {
@@ -184,6 +187,36 @@ class CircuitTerminationForm(NetBoxModelForm):
             ),
         }
 
+    def __init__(self, *args, **kwargs):
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {})
+
+        if instance is not None and instance.termination:
+            initial['termination'] = instance.termination
+            kwargs['initial'] = initial
+
+        super().__init__(*args, **kwargs)
+
+        if termination_type_id := get_field_value(self, 'termination_type'):
+            try:
+                termination_type = ContentType.objects.get(pk=termination_type_id)
+                model = termination_type.model_class()
+                self.fields['termination'].queryset = model.objects.all()
+                self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
+                self.fields['termination'].disabled = False
+                self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
+            except ObjectDoesNotExist:
+                pass
+
+            if self.instance and termination_type_id != self.instance.termination_type_id:
+                self.initial['termination'] = None
+
+    def clean(self):
+        super().clean()
+
+        # Assign the selected termination (if any)
+        self.instance.termination = self.cleaned_data.get('termination')
+
 
 class CircuitGroupForm(TenancyForm, NetBoxModelForm):
     slug = SlugField()

+ 12 - 4
netbox/circuits/graphql/types.py

@@ -1,4 +1,4 @@
-from typing import Annotated, List
+from typing import Annotated, List, Union
 
 import strawberry
 import strawberry_django
@@ -59,13 +59,21 @@ class ProviderNetworkType(NetBoxObjectType):
 
 @strawberry_django.type(
     models.CircuitTermination,
-    fields='__all__',
+    exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'),
     filters=CircuitTerminationFilter
 )
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
     circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
-    provider_network: Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')] | None
-    site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
+
+    @strawberry_django.field
+    def termination(self) -> Annotated[Union[
+        Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')],
+    ], strawberry.union("CircuitTerminationTerminationType")] | None:
+        return self.termination
 
 
 @strawberry_django.type(

+ 56 - 0
netbox/circuits/migrations/0047_circuittermination__termination.py

@@ -0,0 +1,56 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def copy_site_assignments(apps, schema_editor):
+    """
+    Copy site ForeignKey values to the Termination GFK.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    Site = apps.get_model('dcim', 'Site')
+
+    CircuitTermination.objects.filter(site__isnull=False).update(
+        termination_type=ContentType.objects.get_for_model(Site),
+        termination_id=models.F('site_id')
+    )
+
+    ProviderNetwork = apps.get_model('circuits', 'ProviderNetwork')
+    CircuitTermination.objects.filter(provider_network__isnull=False).update(
+        termination_type=ContentType.objects.get_for_model(ProviderNetwork),
+        termination_id=models.F('provider_network_id')
+    )
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0046_charfield_null_choices'),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0193_poweroutlet_color'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittermination',
+            name='termination_id',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='termination_type',
+            field=models.ForeignKey(
+                blank=True,
+                limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork'))),
+                null=True,
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name='+',
+                to='contenttypes.contenttype',
+            ),
+        ),
+
+        # Copy over existing site assignments
+        migrations.RunPython(
+            code=copy_site_assignments,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 90 - 0
netbox/circuits/migrations/0048_circuitterminations_cached_relations.py

@@ -0,0 +1,90 @@
+# Generated by Django 5.0.9 on 2024-10-21 17:34
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def populate_denormalized_fields(apps, schema_editor):
+    """
+    Copy site ForeignKey values to the Termination GFK.
+    """
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+
+    terminations = CircuitTermination.objects.filter(site__isnull=False).prefetch_related('site')
+    for termination in terminations:
+        termination._region_id = termination.site.region_id
+        termination._site_group_id = termination.site.group_id
+        termination._site_id = termination.site_id
+        # Note: Location cannot be set prior to migration
+
+    CircuitTermination.objects.bulk_update(terminations, ['_region', '_site_group', '_site'])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0047_circuittermination__termination'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='circuittermination',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='circuit_terminations',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='_region',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='circuit_terminations',
+                to='dcim.region',
+            ),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='circuit_terminations',
+                to='dcim.site',
+            ),
+        ),
+        migrations.AddField(
+            model_name='circuittermination',
+            name='_site_group',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='circuit_terminations',
+                to='dcim.sitegroup',
+            ),
+        ),
+
+        # Populate denormalized FK values
+        migrations.RunPython(
+            code=populate_denormalized_fields,
+            reverse_code=migrations.RunPython.noop
+        ),
+
+        # Delete the site ForeignKey
+        migrations.RemoveField(
+            model_name='circuittermination',
+            name='site',
+        ),
+        migrations.RenameField(
+            model_name='circuittermination',
+            old_name='provider_network',
+            new_name='_provider_network',
+        ),
+    ]

+ 81 - 13
netbox/circuits/models/circuits.py

@@ -1,9 +1,13 @@
+from django.apps import apps
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import ValidationError
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import *
+from circuits.constants import *
 from dcim.models import CabledObjectModel
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from netbox.models.mixins import DistanceMixin
@@ -230,22 +234,24 @@ class CircuitTermination(
     term_side = models.CharField(
         max_length=1,
         choices=CircuitTerminationSideChoices,
-        verbose_name=_('termination')
+        verbose_name=_('termination side')
     )
-    site = models.ForeignKey(
-        to='dcim.Site',
+    termination_type = models.ForeignKey(
+        to='contenttypes.ContentType',
         on_delete=models.PROTECT,
-        related_name='circuit_terminations',
+        limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
+        related_name='+',
         blank=True,
         null=True
     )
-    provider_network = models.ForeignKey(
-        to='circuits.ProviderNetwork',
-        on_delete=models.PROTECT,
-        related_name='circuit_terminations',
+    termination_id = models.PositiveBigIntegerField(
         blank=True,
         null=True
     )
+    termination = GenericForeignKey(
+        ct_field='termination_type',
+        fk_field='termination_id'
+    )
     port_speed = models.PositiveIntegerField(
         verbose_name=_('port speed (Kbps)'),
         blank=True,
@@ -276,6 +282,43 @@ class CircuitTermination(
         blank=True
     )
 
+    # Cached associations to enable efficient filtering
+    _provider_network = models.ForeignKey(
+        to='circuits.ProviderNetwork',
+        on_delete=models.PROTECT,
+        related_name='circuit_terminations',
+        blank=True,
+        null=True
+    )
+    _location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.CASCADE,
+        related_name='circuit_terminations',
+        blank=True,
+        null=True
+    )
+    _site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        related_name='circuit_terminations',
+        blank=True,
+        null=True
+    )
+    _region = models.ForeignKey(
+        to='dcim.Region',
+        on_delete=models.CASCADE,
+        related_name='circuit_terminations',
+        blank=True,
+        null=True
+    )
+    _site_group = models.ForeignKey(
+        to='dcim.SiteGroup',
+        on_delete=models.CASCADE,
+        related_name='circuit_terminations',
+        blank=True,
+        null=True
+    )
+
     class Meta:
         ordering = ['circuit', 'term_side']
         constraints = (
@@ -297,10 +340,35 @@ class CircuitTermination(
         super().clean()
 
         # Must define either site *or* provider network
-        if self.site is None and self.provider_network is None:
-            raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
-        if self.site and self.provider_network:
-            raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
+        if self.termination is None:
+            raise ValidationError(_("A circuit termination must attach to termination."))
+
+    def save(self, *args, **kwargs):
+        # Cache objects associated with the terminating object (for filtering)
+        self.cache_related_objects()
+
+        super().save(*args, **kwargs)
+
+    def cache_related_objects(self):
+        self._provider_network = self._region = self._site_group = self._site = self._location = None
+        if self.termination_type:
+            termination_type = self.termination_type.model_class()
+            if termination_type == apps.get_model('dcim', 'region'):
+                self._region = self.termination
+            elif termination_type == apps.get_model('dcim', 'sitegroup'):
+                self._site_group = self.termination
+            elif termination_type == apps.get_model('dcim', 'site'):
+                self._region = self.termination.region
+                self._site_group = self.termination.group
+                self._site = self.termination
+            elif termination_type == apps.get_model('dcim', 'location'):
+                self._region = self.termination.site.region
+                self._site_group = self.termination.site.group
+                self._site = self.termination.site
+                self._location = self.termination
+            elif termination_type == apps.get_model('circuits', 'providernetwork'):
+                self._provider_network = self.termination
+    cache_related_objects.alters_data = True
 
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
@@ -314,7 +382,7 @@ class CircuitTermination(
     def get_peer_termination(self):
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         try:
-            return CircuitTermination.objects.prefetch_related('site').get(
+            return CircuitTermination.objects.prefetch_related('termination').get(
                 circuit=self.circuit,
                 term_side=peer_side
             )

+ 41 - 11
netbox/circuits/tables/circuits.py

@@ -18,10 +18,8 @@ __all__ = (
 
 
 CIRCUITTERMINATION_LINK = """
-{% if value.site %}
-  <a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a>
-{% elif value.provider_network %}
-  <a href="{{ value.provider_network.get_absolute_url }}">{{ value.provider_network }}</a>
+{% if value.termination %}
+  <a href="{{ value.termination.get_absolute_url }}">{{ value.termination }}</a>
 {% endif %}
 """
 
@@ -63,12 +61,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Account')
     )
     status = columns.ChoiceFieldColumn()
-    termination_a = tables.TemplateColumn(
+    termination_a = columns.TemplateColumn(
         template_code=CIRCUITTERMINATION_LINK,
         orderable=False,
         verbose_name=_('Side A')
     )
-    termination_z = tables.TemplateColumn(
+    termination_z = columns.TemplateColumn(
         template_code=CIRCUITTERMINATION_LINK,
         orderable=False,
         verbose_name=_('Side Z')
@@ -110,22 +108,54 @@ class CircuitTerminationTable(NetBoxTable):
         linkify=True,
         accessor='circuit.provider'
     )
+    term_side = tables.Column(
+        verbose_name=_('Side')
+    )
+    termination_type = columns.ContentTypeColumn(
+        verbose_name=_('Termination Type'),
+    )
+    termination = tables.Column(
+        verbose_name=_('Termination Point'),
+        linkify=True
+    )
+
+    # Termination types
     site = tables.Column(
         verbose_name=_('Site'),
-        linkify=True
+        linkify=True,
+        accessor='_site'
+    )
+    site_group = tables.Column(
+        verbose_name=_('Site Group'),
+        linkify=True,
+        accessor='_sitegroup'
+    )
+    region = tables.Column(
+        verbose_name=_('Region'),
+        linkify=True,
+        accessor='_region'
+    )
+    location = tables.Column(
+        verbose_name=_('Location'),
+        linkify=True,
+        accessor='_location'
     )
     provider_network = tables.Column(
         verbose_name=_('Provider Network'),
-        linkify=True
+        linkify=True,
+        accessor='_provider_network'
     )
 
     class Meta(NetBoxTable.Meta):
         model = CircuitTermination
         fields = (
-            'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
-            'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions',
+            'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'site_group', 'region',
+            'site', 'location', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
+            'description', 'created', 'last_updated', 'actions',
+        )
+        default_columns = (
+            'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'description',
         )
-        default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')
 
 
 class CircuitGroupTable(NetBoxTable):

+ 8 - 6
netbox/circuits/tests/test_api.py

@@ -181,10 +181,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
         Circuit.objects.bulk_create(circuits)
 
         circuit_terminations = (
-            CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]),
-            CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]),
-            CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]),
-            CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]),
+            CircuitTermination(circuit=circuits[0], term_side=SIDE_A, termination=sites[0]),
+            CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, termination=provider_networks[0]),
+            CircuitTermination(circuit=circuits[1], term_side=SIDE_A, termination=sites[1]),
+            CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, termination=provider_networks[1]),
         )
         CircuitTermination.objects.bulk_create(circuit_terminations)
 
@@ -192,13 +192,15 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
             {
                 'circuit': circuits[2].pk,
                 'term_side': SIDE_A,
-                'site': sites[0].pk,
+                'termination_type': 'dcim.site',
+                'termination_id': sites[0].pk,
                 'port_speed': 200000,
             },
             {
                 'circuit': circuits[2].pk,
                 'term_side': SIDE_Z,
-                'provider_network': provider_networks[0].pk,
+                'termination_type': 'circuits.providernetwork',
+                'termination_id': provider_networks[0].pk,
                 'port_speed': 200000,
             },
         ]

+ 26 - 22
netbox/circuits/tests/test_filtersets.py

@@ -70,10 +70,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Circuit.objects.bulk_create(circuits)
 
-        CircuitTermination.objects.bulk_create((
-            CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
-            CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
-        ))
+        circuit_terminations = (
+            CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
+            CircuitTermination(circuit=circuits[1], termination=sites[0], term_side='A'),
+        )
+        for ct in circuit_terminations:
+            ct.save()
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -233,14 +235,15 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         Circuit.objects.bulk_create(circuits)
 
         circuit_terminations = ((
-            CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
-            CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'),
-            CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'),
-            CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
-            CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
-            CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
+            CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
+            CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
+            CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
+            CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
+            CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
+            CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'),
         ))
-        CircuitTermination.objects.bulk_create(circuit_terminations)
+        for ct in circuit_terminations:
+            ct.save()
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -384,18 +387,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         Circuit.objects.bulk_create(circuits)
 
         circuit_terminations = ((
-            CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
-            CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
-            CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
-            CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
-            CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
-            CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'),
-            CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
-            CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
-            CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
-            CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True),
+            CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
+            CircuitTermination(circuit=circuits[0], termination=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
+            CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
+            CircuitTermination(circuit=circuits[1], termination=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
+            CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
+            CircuitTermination(circuit=circuits[2], termination=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'),
+            CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
+            CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
+            CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'),
+            CircuitTermination(circuit=circuits[6], termination=provider_networks[0], term_side='A', mark_connected=True),
         ))
-        CircuitTermination.objects.bulk_create(circuit_terminations)
+        for ct in circuit_terminations:
+            ct.save()
 
         Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save()
 

+ 27 - 19
netbox/circuits/tests/test_views.py

@@ -1,5 +1,6 @@
 import datetime
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
 
@@ -190,27 +191,31 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_bulk_import_objects_with_terminations(self):
-        json_data = """
+        site = Site.objects.first()
+        json_data = f"""
             [
-              {
+              {{
                 "cid": "Circuit 7",
                 "provider": "Provider 1",
                 "type": "Circuit Type 1",
                 "status": "active",
                 "description": "Testing Import",
                 "terminations": [
-                  {
+                  {{
                     "term_side": "A",
-                    "site": "Site 1"
-                  },
-                  {
+                    "termination_type": "dcim.site",
+                    "termination_id": "{site.pk}"
+                  }},
+                  {{
                     "term_side": "Z",
-                    "site": "Site 1"
-                  }
+                    "termination_type": "dcim.site",
+                    "termination_id": "{site.pk}"
+                  }}
                 ]
-              }
+              }}
             ]
         """
+
         initial_count = self._get_queryset().count()
         data = {
             'data': json_data,
@@ -336,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
-class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+class  TestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = CircuitTermination
 
     @classmethod
@@ -359,24 +364,27 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         Circuit.objects.bulk_create(circuits)
 
         circuit_terminations = (
-            CircuitTermination(circuit=circuits[0], term_side='A', site=sites[0]),
-            CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]),
-            CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]),
-            CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]),
+            CircuitTermination(circuit=circuits[0], term_side='A', termination=sites[0]),
+            CircuitTermination(circuit=circuits[0], term_side='Z', termination=sites[1]),
+            CircuitTermination(circuit=circuits[1], term_side='A', termination=sites[0]),
+            CircuitTermination(circuit=circuits[1], term_side='Z', termination=sites[1]),
         )
-        CircuitTermination.objects.bulk_create(circuit_terminations)
+        for ct in circuit_terminations:
+            ct.save()
 
         cls.form_data = {
             'circuit': circuits[2].pk,
             'term_side': 'A',
-            'site': sites[2].pk,
+            'termination_type': ContentType.objects.get_for_model(Site).pk,
+            'termination': sites[2].pk,
             'description': 'New description',
         }
 
+        site = sites[0].pk
         cls.csv_data = (
-            "circuit,term_side,site,description",
-            "Circuit 3,A,Site 1,Foo",
-            "Circuit 3,Z,Site 1,Bar",
+            "circuit,term_side,termination_type,termination_id,description",
+            f"Circuit 3,A,dcim.site,{site},Foo",
+            f"Circuit 3,Z,dcim.site,{site},Bar",
         )
 
         cls.csv_update_data = (

+ 4 - 7
netbox/circuits/views.py

@@ -158,7 +158,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
                 instance,
                 extra=(
                     (
-                        Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
+                        Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
                         'provider_network_id',
                     ),
                 ),
@@ -257,8 +257,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
 
 class CircuitListView(generic.ObjectListView):
     queryset = Circuit.objects.prefetch_related(
-        'tenant__group', 'termination_a__site', 'termination_z__site',
-        'termination_a__provider_network', 'termination_z__provider_network',
+        'tenant__group', 'termination_a__termination', 'termination_z__termination',
     )
     filterset = filtersets.CircuitFilterSet
     filterset_form = forms.CircuitFilterForm
@@ -298,8 +297,7 @@ class CircuitBulkImportView(generic.BulkImportView):
 
 class CircuitBulkEditView(generic.BulkEditView):
     queryset = Circuit.objects.prefetch_related(
-        'termination_a__site', 'termination_z__site',
-        'termination_a__provider_network', 'termination_z__provider_network',
+        'tenant__group', 'termination_a__termination', 'termination_z__termination',
     )
     filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
@@ -308,8 +306,7 @@ class CircuitBulkEditView(generic.BulkEditView):
 
 class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
-        'termination_a__site', 'termination_z__site',
-        'termination_a__provider_network', 'termination_z__provider_network',
+        'tenant__group', 'termination_a__termination', 'termination_z__termination',
     )
     filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable

+ 16 - 1
netbox/dcim/graphql/types.py

@@ -462,6 +462,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
 
+    @strawberry_django.field
+    def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
+        return self.circuit_terminations.all()
+
 
 @strawberry_django.type(
     models.Manufacturer,
@@ -705,6 +709,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
+    @strawberry_django.field
+    def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
+        return self.circuit_terminations.all()
+
 
 @strawberry_django.type(
     models.Site,
@@ -726,10 +734,13 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
-    circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
     clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
     vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
 
+    @strawberry_django.field
+    def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
+        return self.circuit_terminations.all()
+
 
 @strawberry_django.type(
     models.SiteGroup,
@@ -746,6 +757,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
+    @strawberry_django.field
+    def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
+        return self.circuit_terminations.all()
+
 
 @strawberry_django.type(
     models.VirtualChassis,

+ 6 - 6
netbox/dcim/models/cables.py

@@ -344,7 +344,7 @@ class CableTermination(ChangeLoggedModel):
             )
 
         # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
-        if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
+        if self.termination_type.model == 'circuittermination' and self.termination._provider_network is not None:
             raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
 
     def save(self, *args, **kwargs):
@@ -690,19 +690,19 @@ class CablePath(models.Model):
                 ).first()
                 if circuit_termination is None:
                     break
-                elif circuit_termination.provider_network:
+                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)],
+                        [object_to_path_node(circuit_termination._provider_network)],
                     ])
                     is_complete = True
                     break
-                elif circuit_termination.site and not circuit_termination.cable:
-                    # Circuit terminates to a Site
+                elif circuit_termination.termination and not circuit_termination.cable:
+                    # Circuit terminates to a Region/Site/etc.
                     path.extend([
                         [object_to_path_node(circuit_termination)],
-                        [object_to_path_node(circuit_termination.site)],
+                        [object_to_path_node(circuit_termination.termination)],
                     ])
                     break
 

+ 15 - 15
netbox/dcim/tests/test_cablepaths.py

@@ -1167,7 +1167,7 @@ class CablePathTestCase(TestCase):
         [IF1] --C1-- [CT1]
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
-        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
+        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
 
         # Create cable 1
         cable1 = Cable(
@@ -1198,7 +1198,7 @@ class CablePathTestCase(TestCase):
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
+        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
 
         # Create cable 1
         cable1 = Cable(
@@ -1214,7 +1214,7 @@ class CablePathTestCase(TestCase):
         )
 
         # Create CT2
-        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
+        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
 
         # Check for partial path to site
         self.assertPathExists(
@@ -1266,7 +1266,7 @@ class CablePathTestCase(TestCase):
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
-        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
+        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
 
         # Create cable 1
         cable1 = Cable(
@@ -1282,7 +1282,7 @@ class CablePathTestCase(TestCase):
         )
 
         # Create CT2
-        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
+        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
 
         # Check for partial path to site
         self.assertPathExists(
@@ -1335,8 +1335,8 @@ class CablePathTestCase(TestCase):
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         site2 = Site.objects.create(name='Site 2', slug='site-2')
-        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
-        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z')
+        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
+        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=site2, term_side='Z')
 
         # Create cable 1
         cable1 = Cable(
@@ -1365,8 +1365,8 @@ class CablePathTestCase(TestCase):
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
-        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
-        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z')
+        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
+        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=providernetwork, term_side='Z')
 
         # Create cable 1
         cable1 = Cable(
@@ -1413,8 +1413,8 @@ class CablePathTestCase(TestCase):
         frontport2_2 = FrontPort.objects.create(
             device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
         )
-        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
-        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
+        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
+        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
 
         # Create cables
         cable1 = Cable(
@@ -1499,10 +1499,10 @@ class CablePathTestCase(TestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 2')
-        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
-        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
-        circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A')
-        circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z')
+        circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
+        circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='Z')
+        circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='A')
+        circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='Z')
 
         # Create cables
         cable1 = Cable(

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

@@ -5135,7 +5135,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         provider = Provider.objects.create(name='Provider 1', slug='provider-1')
         circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
         circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type)
-        circuit_termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', site=sites[0])
+        circuit_termination = CircuitTermination.objects.create(circuit=circuit, term_side='A', termination=sites[0])
 
         # Cables
         cables = (
@@ -5308,9 +5308,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
     def test_site(self):
         site = Site.objects.all()[:2]
         params = {'site_id': [site[0].pk, site[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 12)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11)
         params = {'site': [site[0].slug, site[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 12)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11)
 
     def test_tenant(self):
         tenant = Tenant.objects.all()[:2]

+ 3 - 3
netbox/dcim/tests/test_models.py

@@ -762,9 +762,9 @@ class CableTestCase(TestCase):
         circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
         circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
         circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
-        CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
-        CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z')
-        CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A')
+        CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='A')
+        CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='Z')
+        CircuitTermination.objects.create(circuit=circuit2, termination=provider_network, term_side='A')
 
     def test_cable_creation(self):
         """

+ 23 - 3
netbox/dcim/views.py

@@ -242,6 +242,10 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
                 extra=(
                     (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
                     (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
+                    (
+                        Circuit.objects.restrict(request.user, 'view').filter(terminations___region=instance).distinct(),
+                        'region_id'
+                    ),
                 ),
             ),
         }
@@ -324,6 +328,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
                 extra=(
                     (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
                     (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
+                    (
+                        Circuit.objects.restrict(request.user, 'view').filter(terminations___site_group=instance).distinct(),
+                        'site_group_id'
+                    ),
                 ),
             ),
         }
@@ -404,8 +412,10 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
                         scope_id=instance.pk
                     ), 'site'),
                     (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
-                    (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(),
-                     'site_id'),
+                    (
+                        Circuit.objects.restrict(request.user, 'view').filter(terminations___site=instance).distinct(),
+                        'site_id'
+                    ),
                 ),
             ),
         }
@@ -475,7 +485,17 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
     def get_extra_context(self, request, instance):
         locations = instance.get_descendants(include_self=True)
         return {
-            'related_models': self.get_related_models(request, locations, [CableTermination]),
+            'related_models': self.get_related_models(
+                request,
+                locations,
+                [CableTermination],
+                (
+                    (
+                        Circuit.objects.restrict(request.user, 'view').filter(terminations___location=instance).distinct(),
+                        'location_id'
+                    ),
+                ),
+            ),
         }
 
 

+ 10 - 15
netbox/templates/circuits/inc/circuit_termination_fields.html

@@ -1,18 +1,19 @@
 {% load helpers %}
 {% load i18n %}
 
-{% if termination.site %}
   <tr>
-    <th scope="row">{% trans "Site" %}</th>
-    <td>
-      {% if termination.site.region %}
-        {{ termination.site.region|linkify }} /
-      {% endif %}
-      {{ termination.site|linkify }}
-    </td>
+    <th scope="row">{% trans "Termination point" %}</th>
+    {% if termination.termination %}
+      <td>
+        {{ termination.termination|linkify }}
+        <div class="fs-5 text-muted">{% trans termination.termination_type.name|bettertitle %}</div>
+      </td>
+    {% else %}
+      <td>{{ ''|placeholder }}</td>
+    {% endif %}
   </tr>
   <tr>
-    <th scope="row">{% trans "Termination" %}</th>
+    <th scope="row">{% trans "Connection" %}</th>
     <td>
       {% if termination.mark_connected %}
         <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
@@ -57,12 +58,6 @@
       {% endif %}
     </td>
   </tr>
-{% else %}
-  <tr>
-    <th scope="row">{% trans "Provider Network" %}</th>
-    <td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
-  </tr>
-{% endif %}
   <tr>
       <th scope="row">{% trans "Speed" %}</th>
       <td>