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>
This commit is contained in:
Arthur Hanson 2024-10-31 06:55:08 -07:00 committed by GitHub
parent f74a9a1c76
commit a8eb455f3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 649 additions and 211 deletions

View File

@ -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). 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).
### 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).
### Port Speed ### Port Speed

View File

@ -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, ContentTypeField, RelatedObjectCountField
from netbox.api.fields import ChoiceField, 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)

View File

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

View File

@ -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',
) )

View File

@ -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 import add_blank_choice, get_field_value
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import (
from utilities.forms.rendering import FieldSet, TabbedGroups ColorField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions )
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( termination_type = ContentTypeChoiceField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
queryset=Site.objects.all(), widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
required=False required=False,
label=_('Termination type')
) )
provider_network = DynamicModelChoiceField( termination = DynamicModelChoiceField(
label=_('Provider Network'), label=_('Termination'),
queryset=ProviderNetwork.objects.all(), queryset=Site.objects.none(), # Initial queryset
required=False 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( 'termination_type', 'termination',
FieldSet('site', name=_('Site')),
FieldSet('provider_network', name=_('Provider Network')),
),
'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):

View File

@ -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( termination_type = CSVContentTypeField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
queryset=Site.objects.all(), required=False,
to_field_name='name', label=_('Termination type (app & model)')
required=False
)
provider_network = CSVModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
to_field_name='name',
required=False
) )
@ -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):

View File

@ -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('provider_id', name=_('Provider')),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), 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,

View File

@ -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 import get_field_value
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
from utilities.forms.widgets import DatePicker, NumberWithOptions 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( termination_type = ContentTypeChoiceField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
queryset=Site.objects.all(), widget=HTMXSelect(),
required=False, required=False,
selector=True label=_('Termination type')
) )
provider_network = DynamicModelChoiceField( termination = DynamicModelChoiceField(
label=_('Provider network'), label=_('Termination'),
queryset=ProviderNetwork.objects.all(), 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( 'termination_type', 'termination',
FieldSet('site', name=_('Site')),
FieldSet('provider_network', name=_('Provider Network')),
),
'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()

View File

@ -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(

View File

@ -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
),
]

View File

@ -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',
),
]

View File

@ -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( termination_type = models.ForeignKey(
to='dcim.Site', 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( termination_id = models.PositiveBigIntegerField(
to='circuits.ProviderNetwork',
on_delete=models.PROTECT,
related_name='circuit_terminations',
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: if self.termination is None:
raise ValidationError(_("A circuit termination must attach to either a site or a provider network.")) raise ValidationError(_("A circuit termination must attach to termination."))
if self.site and self.provider_network:
raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network.")) 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
) )

View File

@ -18,10 +18,8 @@ __all__ = (
CIRCUITTERMINATION_LINK = """ CIRCUITTERMINATION_LINK = """
{% if value.site %} {% if value.termination %}
<a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a> <a href="{{ value.termination.get_absolute_url }}">{{ value.termination }}</a>
{% elif value.provider_network %}
<a href="{{ value.provider_network.get_absolute_url }}">{{ value.provider_network }}</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', 'pk', 'id', 'circuit', 'provider', 'term_side', 'termination_type', 'termination', 'site_group', 'region',
'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions', '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):

View File

@ -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_A, termination=sites[0]),
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]), CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, termination=provider_networks[0]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]), CircuitTermination(circuit=circuits[1], term_side=SIDE_A, termination=sites[1]),
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[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,
}, },
] ]

View File

@ -70,10 +70,12 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
CircuitTermination.objects.bulk_create(( circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=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[0], termination=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], 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], termination=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[0], termination=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], termination=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[1], termination=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], termination=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[2], termination=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[3], termination=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), CircuitTermination(circuit=circuits[5], termination=provider_networks[2], term_side='A'),
CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), 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()

View File

@ -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='A', termination=sites[0]),
CircuitTermination(circuit=circuits[0], term_side='Z', site=sites[1]), CircuitTermination(circuit=circuits[0], term_side='Z', termination=sites[1]),
CircuitTermination(circuit=circuits[1], term_side='A', site=sites[0]), CircuitTermination(circuit=circuits[1], term_side='A', termination=sites[0]),
CircuitTermination(circuit=circuits[1], term_side='Z', site=sites[1]), 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,term_side,termination_type,termination_id,description",
"Circuit 3,A,Site 1,Foo", f"Circuit 3,A,dcim.site,{site},Foo",
"Circuit 3,Z,Site 1,Bar", f"Circuit 3,Z,dcim.site,{site},Bar",
) )
cls.csv_update_data = ( cls.csv_update_data = (

View File

@ -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', 'tenant__group', 'termination_a__termination', 'termination_z__termination',
'termination_a__provider_network', 'termination_z__provider_network',
) )
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', 'tenant__group', 'termination_a__termination', 'termination_z__termination',
'termination_a__provider_network', 'termination_z__provider_network',
) )
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', 'tenant__group', 'termination_a__termination', 'termination_z__termination',
'termination_a__provider_network', 'termination_z__provider_network',
) )
filterset = filtersets.CircuitFilterSet filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable table = tables.CircuitTable

View File

@ -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,

View File

@ -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: elif circuit_termination.termination and not circuit_termination.cable:
# Circuit terminates to a Site # 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

View File

@ -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') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') 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') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') 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') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
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')
# 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') circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
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')
circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='A') circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='A')
circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, termination=self.site, term_side='Z')
# Create cables # Create cables
cable1 = Cable( cable1 = Cable(

View File

@ -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]

View File

@ -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, termination=site, term_side='A')
CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') CircuitTermination.objects.create(circuit=circuit1, termination=site, term_side='Z')
CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') CircuitTermination.objects.create(circuit=circuit2, termination=provider_network, term_side='A')
def test_cable_creation(self): def test_cable_creation(self):
""" """

View File

@ -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'
),
),
),
} }

View File

@ -1,18 +1,19 @@
{% load helpers %} {% load helpers %}
{% load i18n %} {% load i18n %}
{% if termination.site %}
<tr> <tr>
<th scope="row">{% trans "Site" %}</th> <th scope="row">{% trans "Termination point" %}</th>
{% if termination.termination %}
<td> <td>
{% if termination.site.region %} {{ termination.termination|linkify }}
{{ termination.site.region|linkify }} / <div class="fs-5 text-muted">{% trans termination.termination_type.name|bettertitle %}</div>
{% endif %}
{{ termination.site|linkify }}
</td> </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>