Przeglądaj źródła

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 rok temu
rodzic
commit
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.
 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
 ### 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.choices import CircuitPriorityChoices, CircuitStatusChoices
+from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
 from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
 from dcim.api.serializers_.cables import CabledObjectSerializer
 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.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from netbox.choices import DistanceUnitChoices
 from netbox.choices import DistanceUnitChoices
 from tenancy.api.serializers_.tenants import TenantSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
+from utilities.api import get_serializer_for_model
 
 
 from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
 from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
 
 
@@ -33,16 +38,33 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
 
 
 
 
 class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
 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)
     provider_network = ProviderNetworkSerializer(nested=True, allow_null=True)
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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',
             '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):
 class CircuitGroupSerializer(NetBoxModelSerializer):
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
@@ -95,18 +117,35 @@ class CircuitSerializer(NetBoxModelSerializer):
 
 
 class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
 class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
     circuit = CircuitSerializer(nested=True)
     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)
     provider_network = ProviderNetworkSerializer(nested=True, required=False, allow_null=True)
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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',
             'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end',
             'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
             'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_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_):
 class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
     circuit = CircuitSerializer(nested=True)
     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 django.utils.translation import gettext as _
 
 
 from dcim.filtersets import CabledObjectFilterSet
 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 ipam.models import ASN
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
-from utilities.filters import TreeNodeMultipleChoiceFilter
+from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
 
 
@@ -26,37 +26,37 @@ __all__ = (
 class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='circuits__terminations__site__region',
+        field_name='circuits__terminations___region',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Region (ID)'),
         label=_('Region (ID)'),
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='circuits__terminations__site__region',
+        field_name='circuits__terminations___region',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Region (slug)'),
         label=_('Region (slug)'),
     )
     )
     site_group_id = TreeNodeMultipleChoiceFilter(
     site_group_id = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='circuits__terminations__site__group',
+        field_name='circuits__terminations___site_group',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Site group (ID)'),
         label=_('Site group (ID)'),
     )
     )
     site_group = TreeNodeMultipleChoiceFilter(
     site_group = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='circuits__terminations__site__group',
+        field_name='circuits__terminations___site_group',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site group (slug)'),
         label=_('Site group (slug)'),
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='circuits__terminations__site',
+        field_name='circuits__terminations___site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label=_('Site'),
         label=_('Site'),
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='circuits__terminations__site__slug',
+        field_name='circuits__terminations___site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
@@ -173,7 +173,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         label=_('Provider account (account)'),
         label=_('Provider account (account)'),
     )
     )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__provider_network',
+        field_name='terminations___provider_network',
         queryset=ProviderNetwork.objects.all(),
         queryset=ProviderNetwork.objects.all(),
         label=_('Provider network (ID)'),
         label=_('Provider network (ID)'),
     )
     )
@@ -193,37 +193,37 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
     )
     )
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='terminations__site__region',
+        field_name='terminations___region',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Region (ID)'),
         label=_('Region (ID)'),
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='terminations__site__region',
+        field_name='terminations___region',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Region (slug)'),
         label=_('Region (slug)'),
     )
     )
     site_group_id = TreeNodeMultipleChoiceFilter(
     site_group_id = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='terminations__site__group',
+        field_name='terminations___site_group',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Site group (ID)'),
         label=_('Site group (ID)'),
     )
     )
     site_group = TreeNodeMultipleChoiceFilter(
     site_group = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='terminations__site__group',
+        field_name='terminations___site_group',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site group (slug)'),
         label=_('Site group (slug)'),
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__site',
+        field_name='terminations___site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label=_('Site (ID)'),
         label=_('Site (ID)'),
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='terminations__site__slug',
+        field_name='terminations___site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
@@ -263,18 +263,60 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         label=_('Circuit'),
         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(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        field_name='_site',
         label=_('Site (ID)'),
         label=_('Site (ID)'),
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='site__slug',
+        field_name='_site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (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(
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ProviderNetwork.objects.all(),
         queryset=ProviderNetwork.objects.all(),
+        field_name='_provider_network',
         label=_('ProviderNetwork (ID)'),
         label=_('ProviderNetwork (ID)'),
     )
     )
     provider_id = django_filters.ModelMultipleChoiceFilter(
     provider_id = django_filters.ModelMultipleChoiceFilter(
@@ -292,7 +334,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = (
         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',
             'pp_info', 'cable_end',
         )
         )
 
 

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

@@ -1,17 +1,23 @@
 from django import forms
 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 django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
 from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
+from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Site
 from dcim.models import Site
 from ipam.models import ASN
 from ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 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__ = (
 __all__ = (
     'CircuitBulkEditForm',
     'CircuitBulkEditForm',
@@ -197,15 +203,18 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         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(
     port_speed = forms.IntegerField(
         required=False,
         required=False,
@@ -225,15 +234,26 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'description',
             'description',
-            TabbedGroups(
-                FieldSet('site', name=_('Site')),
-                FieldSet('provider_network', name=_('Provider Network')),
-            ),
+            'termination_type', 'termination',
             'mark_connected', name=_('Circuit Termination')
             'mark_connected', name=_('Circuit Termination')
         ),
         ),
         FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
         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):
 class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):

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

@@ -1,13 +1,14 @@
 from django import forms
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import *
 from circuits.choices import *
+from circuits.constants import *
 from circuits.models import *
 from circuits.models import *
-from dcim.models import Site
 from netbox.choices import DistanceUnitChoices
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 
 
 __all__ = (
 __all__ = (
     'CircuitImportForm',
     'CircuitImportForm',
@@ -127,17 +128,10 @@ class BaseCircuitTerminationImportForm(forms.ModelForm):
         label=_('Termination'),
         label=_('Termination'),
         choices=CircuitTerminationSideChoices,
         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:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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'
             'pp_info', 'description'
         ]
         ]
+        labels = {
+            'termination_id': _('Termination ID'),
+        }
 
 
 
 
 class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
 class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
@@ -155,9 +152,12 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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'
             'pp_info', 'description', 'tags'
         ]
         ]
+        labels = {
+            'termination_id': _('Termination ID'),
+        }
 
 
 
 
 class CircuitGroupImportForm(NetBoxModelImportForm):
 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.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices
 from circuits.models import *
 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 ipam.models import ASN
 from netbox.choices import DistanceUnitChoices
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
@@ -207,18 +207,29 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('circuit_id', 'term_side', name=_('Circuit')),
         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(
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        query_params={
-            'region_id': '$region_id',
-            'site_group_id': '$site_group_id',
-        },
         label=_('Site')
         label=_('Site')
     )
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location')
+    )
     circuit_id = DynamicModelMultipleChoiceField(
     circuit_id = DynamicModelMultipleChoiceField(
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         required=False,
         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 django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
 from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
+from circuits.constants import *
 from circuits.models import *
 from circuits.models import *
 from dcim.models import Site
 from dcim.models import Site
 from ipam.models import ASN
 from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 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__ = (
 __all__ = (
     'CircuitForm',
     'CircuitForm',
@@ -144,26 +149,24 @@ class CircuitTerminationForm(NetBoxModelForm):
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         selector=True
         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,
         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,
         required=False,
+        disabled=True,
         selector=True
         selector=True
     )
     )
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             'circuit', 'term_side', 'description', 'tags',
             'circuit', 'term_side', 'description', 'tags',
-            TabbedGroups(
-                FieldSet('site', name=_('Site')),
-                FieldSet('provider_network', name=_('Provider Network')),
-            ),
+            'termination_type', 'termination',
             'mark_connected', name=_('Circuit Termination')
             'mark_connected', name=_('Circuit Termination')
         ),
         ),
         FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
         FieldSet('port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', name=_('Termination Details')),
@@ -172,7 +175,7 @@ class CircuitTerminationForm(NetBoxModelForm):
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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',
             'xconnect_id', 'pp_info', 'description', 'tags',
         ]
         ]
         widgets = {
         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):
 class CircuitGroupForm(TenancyForm, NetBoxModelForm):
     slug = SlugField()
     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
 import strawberry_django
 import strawberry_django
@@ -59,13 +59,21 @@ class ProviderNetworkType(NetBoxObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.CircuitTermination,
     models.CircuitTermination,
-    fields='__all__',
+    exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'),
     filters=CircuitTerminationFilter
     filters=CircuitTerminationFilter
 )
 )
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
     circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
     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(
 @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.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import *
 from circuits.choices import *
+from circuits.constants import *
 from dcim.models import CabledObjectModel
 from dcim.models import CabledObjectModel
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from netbox.models.mixins import DistanceMixin
 from netbox.models.mixins import DistanceMixin
@@ -230,22 +234,24 @@ class CircuitTermination(
     term_side = models.CharField(
     term_side = models.CharField(
         max_length=1,
         max_length=1,
         choices=CircuitTerminationSideChoices,
         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,
         on_delete=models.PROTECT,
-        related_name='circuit_terminations',
+        limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
+        related_name='+',
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
-    provider_network = models.ForeignKey(
-        to='circuits.ProviderNetwork',
-        on_delete=models.PROTECT,
-        related_name='circuit_terminations',
+    termination_id = models.PositiveBigIntegerField(
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    termination = GenericForeignKey(
+        ct_field='termination_type',
+        fk_field='termination_id'
+    )
     port_speed = models.PositiveIntegerField(
     port_speed = models.PositiveIntegerField(
         verbose_name=_('port speed (Kbps)'),
         verbose_name=_('port speed (Kbps)'),
         blank=True,
         blank=True,
@@ -276,6 +282,43 @@ class CircuitTermination(
         blank=True
         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:
     class Meta:
         ordering = ['circuit', 'term_side']
         ordering = ['circuit', 'term_side']
         constraints = (
         constraints = (
@@ -297,10 +340,35 @@ class CircuitTermination(
         super().clean()
         super().clean()
 
 
         # Must define either site *or* provider network
         # 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):
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange = super().to_objectchange(action)
@@ -314,7 +382,7 @@ class CircuitTermination(
     def get_peer_termination(self):
     def get_peer_termination(self):
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         peer_side = 'Z' if self.term_side == 'A' else 'A'
         try:
         try:
-            return CircuitTermination.objects.prefetch_related('site').get(
+            return CircuitTermination.objects.prefetch_related('termination').get(
                 circuit=self.circuit,
                 circuit=self.circuit,
                 term_side=peer_side
                 term_side=peer_side
             )
             )

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

@@ -18,10 +18,8 @@ __all__ = (
 
 
 
 
 CIRCUITTERMINATION_LINK = """
 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 %}
 {% endif %}
 """
 """
 
 
@@ -63,12 +61,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         verbose_name=_('Account')
         verbose_name=_('Account')
     )
     )
     status = columns.ChoiceFieldColumn()
     status = columns.ChoiceFieldColumn()
-    termination_a = tables.TemplateColumn(
+    termination_a = columns.TemplateColumn(
         template_code=CIRCUITTERMINATION_LINK,
         template_code=CIRCUITTERMINATION_LINK,
         orderable=False,
         orderable=False,
         verbose_name=_('Side A')
         verbose_name=_('Side A')
     )
     )
-    termination_z = tables.TemplateColumn(
+    termination_z = columns.TemplateColumn(
         template_code=CIRCUITTERMINATION_LINK,
         template_code=CIRCUITTERMINATION_LINK,
         orderable=False,
         orderable=False,
         verbose_name=_('Side Z')
         verbose_name=_('Side Z')
@@ -110,22 +108,54 @@ class CircuitTerminationTable(NetBoxTable):
         linkify=True,
         linkify=True,
         accessor='circuit.provider'
         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(
     site = tables.Column(
         verbose_name=_('Site'),
         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(
     provider_network = tables.Column(
         verbose_name=_('Provider Network'),
         verbose_name=_('Provider Network'),
-        linkify=True
+        linkify=True,
+        accessor='_provider_network'
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = CircuitTermination
         model = CircuitTermination
         fields = (
         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):
 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.objects.bulk_create(circuits)
 
 
         circuit_terminations = (
         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)
         CircuitTermination.objects.bulk_create(circuit_terminations)
 
 
@@ -192,13 +192,15 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'circuit': circuits[2].pk,
                 'circuit': circuits[2].pk,
                 'term_side': SIDE_A,
                 'term_side': SIDE_A,
-                'site': sites[0].pk,
+                'termination_type': 'dcim.site',
+                'termination_id': sites[0].pk,
                 'port_speed': 200000,
                 'port_speed': 200000,
             },
             },
             {
             {
                 'circuit': circuits[2].pk,
                 'circuit': circuits[2].pk,
                 'term_side': SIDE_Z,
                 'term_side': SIDE_Z,
-                'provider_network': provider_networks[0].pk,
+                'termination_type': 'circuits.providernetwork',
+                'termination_id': provider_networks[0].pk,
                 'port_speed': 200000,
                 'port_speed': 200000,
             },
             },
         ]
         ]

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

@@ -70,10 +70,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         Circuit.objects.bulk_create(circuits)
         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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -233,14 +235,15 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
         Circuit.objects.bulk_create(circuits)
         Circuit.objects.bulk_create(circuits)
 
 
         circuit_terminations = ((
         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):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -384,18 +387,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         Circuit.objects.bulk_create(circuits)
         Circuit.objects.bulk_create(circuits)
 
 
         circuit_terminations = ((
         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()
         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
 import datetime
 
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 
 
@@ -190,27 +191,31 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_bulk_import_objects_with_terminations(self):
     def test_bulk_import_objects_with_terminations(self):
-        json_data = """
+        site = Site.objects.first()
+        json_data = f"""
             [
             [
-              {
+              {{
                 "cid": "Circuit 7",
                 "cid": "Circuit 7",
                 "provider": "Provider 1",
                 "provider": "Provider 1",
                 "type": "Circuit Type 1",
                 "type": "Circuit Type 1",
                 "status": "active",
                 "status": "active",
                 "description": "Testing Import",
                 "description": "Testing Import",
                 "terminations": [
                 "terminations": [
-                  {
+                  {{
                     "term_side": "A",
                     "term_side": "A",
-                    "site": "Site 1"
-                  },
-                  {
+                    "termination_type": "dcim.site",
+                    "termination_id": "{site.pk}"
+                  }},
+                  {{
                     "term_side": "Z",
                     "term_side": "Z",
-                    "site": "Site 1"
-                  }
+                    "termination_type": "dcim.site",
+                    "termination_id": "{site.pk}"
+                  }}
                 ]
                 ]
-              }
+              }}
             ]
             ]
         """
         """
+
         initial_count = self._get_queryset().count()
         initial_count = self._get_queryset().count()
         data = {
         data = {
             'data': json_data,
             'data': json_data,
@@ -336,7 +341,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
-class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+class  TestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = CircuitTermination
     model = CircuitTermination
 
 
     @classmethod
     @classmethod
@@ -359,24 +364,27 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         Circuit.objects.bulk_create(circuits)
         Circuit.objects.bulk_create(circuits)
 
 
         circuit_terminations = (
         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 = {
         cls.form_data = {
             'circuit': circuits[2].pk,
             'circuit': circuits[2].pk,
             'term_side': 'A',
             'term_side': 'A',
-            'site': sites[2].pk,
+            'termination_type': ContentType.objects.get_for_model(Site).pk,
+            'termination': sites[2].pk,
             'description': 'New description',
             'description': 'New description',
         }
         }
 
 
+        site = sites[0].pk
         cls.csv_data = (
         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 = (
         cls.csv_update_data = (

+ 4 - 7
netbox/circuits/views.py

@@ -158,7 +158,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
                 instance,
                 instance,
                 extra=(
                 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',
                         'provider_network_id',
                     ),
                     ),
                 ),
                 ),
@@ -257,8 +257,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
 
 
 class CircuitListView(generic.ObjectListView):
 class CircuitListView(generic.ObjectListView):
     queryset = Circuit.objects.prefetch_related(
     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 = filtersets.CircuitFilterSet
     filterset_form = forms.CircuitFilterForm
     filterset_form = forms.CircuitFilterForm
@@ -298,8 +297,7 @@ class CircuitBulkImportView(generic.BulkImportView):
 
 
 class CircuitBulkEditView(generic.BulkEditView):
 class CircuitBulkEditView(generic.BulkEditView):
     queryset = Circuit.objects.prefetch_related(
     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
     filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
     table = tables.CircuitTable
@@ -308,8 +306,7 @@ class CircuitBulkEditView(generic.BulkEditView):
 
 
 class CircuitBulkDeleteView(generic.BulkDeleteView):
 class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
     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
     filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
     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')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     children: List[Annotated["LocationType", 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(
 @strawberry_django.type(
     models.Manufacturer,
     models.Manufacturer,
@@ -705,6 +709,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         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(
 @strawberry_django.type(
     models.Site,
     models.Site,
@@ -726,10 +734,13 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     asns: List[Annotated["ASNType", strawberry.lazy('ipam.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')]]
     clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
     vlans: List[Annotated["VLANType", strawberry.lazy('ipam.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(
 @strawberry_django.type(
     models.SiteGroup,
     models.SiteGroup,
@@ -746,6 +757,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         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(
 @strawberry_django.type(
     models.VirtualChassis,
     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
         # 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."))
             raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -690,19 +690,19 @@ class CablePath(models.Model):
                 ).first()
                 ).first()
                 if circuit_termination is None:
                 if circuit_termination is None:
                     break
                     break
-                elif circuit_termination.provider_network:
+                elif circuit_termination._provider_network:
                     # Circuit terminates to a ProviderNetwork
                     # Circuit terminates to a ProviderNetwork
                     path.extend([
                     path.extend([
                         [object_to_path_node(circuit_termination)],
                         [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
                     is_complete = True
                     break
                     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([
                     path.extend([
                         [object_to_path_node(circuit_termination)],
                         [object_to_path_node(circuit_termination)],
-                        [object_to_path_node(circuit_termination.site)],
+                        [object_to_path_node(circuit_termination.termination)],
                     ])
                     ])
                     break
                     break
 
 

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

@@ -1167,7 +1167,7 @@ class CablePathTestCase(TestCase):
         [IF1] --C1-- [CT1]
         [IF1] --C1-- [CT1]
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         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
         # Create cable 1
         cable1 = Cable(
         cable1 = Cable(
@@ -1198,7 +1198,7 @@ class CablePathTestCase(TestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        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
         # Create cable 1
         cable1 = Cable(
         cable1 = Cable(
@@ -1214,7 +1214,7 @@ class CablePathTestCase(TestCase):
         )
         )
 
 
         # Create CT2
         # 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
         # Check for partial path to site
         self.assertPathExists(
         self.assertPathExists(
@@ -1266,7 +1266,7 @@ class CablePathTestCase(TestCase):
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
-        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
         # Create cable 1
         cable1 = Cable(
         cable1 = Cable(
@@ -1282,7 +1282,7 @@ class CablePathTestCase(TestCase):
         )
         )
 
 
         # Create CT2
         # 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
         # Check for partial path to site
         self.assertPathExists(
         self.assertPathExists(
@@ -1335,8 +1335,8 @@ class CablePathTestCase(TestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         site2 = Site.objects.create(name='Site 2', slug='site-2')
         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
         # Create cable 1
         cable1 = Cable(
         cable1 = Cable(
@@ -1365,8 +1365,8 @@ class CablePathTestCase(TestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
         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
         # Create cable 1
         cable1 = Cable(
         cable1 = Cable(
@@ -1413,8 +1413,8 @@ class CablePathTestCase(TestCase):
         frontport2_2 = FrontPort.objects.create(
         frontport2_2 = FrontPort.objects.create(
             device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
             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
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -1499,10 +1499,10 @@ class CablePathTestCase(TestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         circuit2 = Circuit.objects.create(provider=self.circuit.provider, type=self.circuit.type, cid='Circuit 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
         # Create cables
         cable1 = Cable(
         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')
         provider = Provider.objects.create(name='Provider 1', slug='provider-1')
         circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-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 = 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
         cables = (
         cables = (
@@ -5308,9 +5308,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
     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]}
-        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]}
         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):
     def test_tenant(self):
         tenant = Tenant.objects.all()[:2]
         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')
         circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
         circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
         circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
         circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
         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):
     def test_cable_creation(self):
         """
         """

+ 23 - 3
netbox/dcim/views.py

@@ -242,6 +242,10 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
                 extra=(
                 extra=(
                     (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
                     (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'),
                     (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=(
                 extra=(
                     (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
                     (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'),
                     (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
                         scope_id=instance.pk
                     ), 'site'),
                     ), 'site'),
                     (ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
                     (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):
     def get_extra_context(self, request, instance):
         locations = instance.get_descendants(include_self=True)
         locations = instance.get_descendants(include_self=True)
         return {
         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 helpers %}
 {% load i18n %}
 {% load i18n %}
 
 
-{% if termination.site %}
   <tr>
   <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>
   <tr>
   <tr>
-    <th scope="row">{% trans "Termination" %}</th>
+    <th scope="row">{% trans "Connection" %}</th>
     <td>
     <td>
       {% if termination.mark_connected %}
       {% if termination.mark_connected %}
         <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
         <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
@@ -57,12 +58,6 @@
       {% endif %}
       {% endif %}
     </td>
     </td>
   </tr>
   </tr>
-{% else %}
-  <tr>
-    <th scope="row">{% trans "Provider Network" %}</th>
-    <td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
-  </tr>
-{% endif %}
   <tr>
   <tr>
       <th scope="row">{% trans "Speed" %}</th>
       <th scope="row">{% trans "Speed" %}</th>
       <td>
       <td>