Skip to content

Commit c3b0de3

Browse files
Closes #18281: Support group assignment for virtual circuits (#18291)
* Rename circuit to member on CircuitGroupAssignment * Support group assignment for virtual circuits * Update release notes * Introduce separate nav menu heading for circuit groups * Add generic relations for group assignments * Remove obsolete code * Clean up bulk import & extend tests
1 parent 6f4bec7 commit c3b0de3

21 files changed

+403
-80
lines changed

docs/models/circuits/circuitgroupassignment.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ Circuits can be assigned to [circuit groups](./circuitgroup.md) for correlation
88

99
The [circuit group](./circuitgroup.md) being assigned.
1010

11-
### Circuit
11+
### Member
1212

13-
The [circuit](./circuit.md) that is being assigned to the group.
13+
The [circuit](./circuit.md) or [virtual circuit](./virtualcircuit.md) assigned to the group.
1414

1515
### Priority
1616

docs/release-notes/version-4.2.md

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* The `site` and `provider_network` foreign key fields on `circuits.CircuitTermination` have been replaced by the `termination` generic foreign key.
1414
* The `site` foreign key field on `ipam.Prefix` has been replaced by the `scope` generic foreign key.
1515
* The `site` foreign key field on `virtualization.Cluster` has been replaced by the `scope` generic foreign key.
16+
* The `circuit` foreign key field on `circuits.CircuitGroupAssignment` has been replaced by the `member` generic foreign key.
1617
* Obsolete nested REST API serializers have been removed. These were deprecated in NetBox v4.1 under [#17143](https://github.com/netbox-community/netbox/issues/17143).
1718

1819
### New Features
@@ -77,6 +78,8 @@ NetBox now supports the designation of customer VLANs (CVLANs) and service VLANs
7778
* `/api/ipam/vlan-translation-rules/`
7879
* circuits.Circuit
7980
* Added the optional `distance` and `distance_unit` fields
81+
* circuits.CircuitGroupAssignment
82+
* Replaced the `circuit` field with `member_type` and `member_id` to support virtual circuit assignment
8083
* circuits.CircuitTermination
8184
* Removed the `site` & `provider_network` fields
8285
* Added the `termination_type` & `termination_id` fields to facilitate termination assignment

netbox/circuits/api/serializers_/circuits.py

+16-5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from rest_framework import serializers
44

55
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
6-
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
6+
from circuits.constants import CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, CIRCUIT_TERMINATION_TERMINATION_TYPES
77
from circuits.models import (
88
Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
99
VirtualCircuitTermination,
@@ -15,7 +15,6 @@
1515
from netbox.choices import DistanceUnitChoices
1616
from tenancy.api.serializers_.tenants import TenantSerializer
1717
from utilities.api import get_serializer_for_model
18-
1918
from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
2019

2120
__all__ = (
@@ -154,14 +153,26 @@ def get_termination(self, obj):
154153

155154

156155
class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
157-
circuit = CircuitSerializer(nested=True)
156+
member_type = ContentTypeField(
157+
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS)
158+
)
159+
member = serializers.SerializerMethodField(read_only=True)
158160

159161
class Meta:
160162
model = CircuitGroupAssignment
161163
fields = [
162-
'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
164+
'id', 'url', 'display_url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority', 'tags',
165+
'created', 'last_updated',
163166
]
164-
brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')
167+
brief_fields = ('id', 'url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority')
168+
169+
@extend_schema_field(serializers.JSONField(allow_null=True))
170+
def get_member(self, obj):
171+
if obj.member_id is None:
172+
return None
173+
serializer = get_serializer_for_model(obj.member)
174+
context = {'request': self.context['request']}
175+
return serializer(obj.member, nested=True, context=context).data
165176

166177

167178
class VirtualCircuitSerializer(NetBoxModelSerializer):

netbox/circuits/constants.py

+8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
from django.db.models import Q
2+
3+
14
# models values for ContentTypes which may be CircuitTermination termination types
25
CIRCUIT_TERMINATION_TERMINATION_TYPES = (
36
'region', 'sitegroup', 'site', 'location', 'providernetwork',
47
)
8+
9+
CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS = Q(
10+
app_label='circuits',
11+
model__in=['circuit', 'virtualcircuit']
12+
)

netbox/circuits/filtersets.py

+72-20
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import django_filters
2+
from django.contrib.contenttypes.models import ContentType
23
from django.db.models import Q
34
from django.utils.translation import gettext as _
45

@@ -7,7 +8,9 @@
78
from ipam.models import ASN
89
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
910
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
10-
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
11+
from utilities.filters import (
12+
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
13+
)
1114
from .choices import *
1215
from .models import *
1316

@@ -365,26 +368,36 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
365368
method='search',
366369
label=_('Search'),
367370
)
368-
provider_id = django_filters.ModelMultipleChoiceFilter(
369-
field_name='circuit__provider',
370-
queryset=Provider.objects.all(),
371-
label=_('Provider (ID)'),
372-
)
373-
provider = django_filters.ModelMultipleChoiceFilter(
374-
field_name='circuit__provider__slug',
375-
queryset=Provider.objects.all(),
376-
to_field_name='slug',
377-
label=_('Provider (slug)'),
371+
member_type = ContentTypeFilter()
372+
circuit = MultiValueCharFilter(
373+
method='filter_circuit',
374+
field_name='cid',
375+
label=_('Circuit (CID)'),
378376
)
379-
circuit_id = django_filters.ModelMultipleChoiceFilter(
380-
queryset=Circuit.objects.all(),
377+
circuit_id = MultiValueNumberFilter(
378+
method='filter_circuit',
379+
field_name='pk',
381380
label=_('Circuit (ID)'),
382381
)
383-
circuit = django_filters.ModelMultipleChoiceFilter(
384-
field_name='circuit__cid',
385-
queryset=Circuit.objects.all(),
386-
to_field_name='cid',
387-
label=_('Circuit (CID)'),
382+
virtual_circuit = MultiValueCharFilter(
383+
method='filter_virtual_circuit',
384+
field_name='cid',
385+
label=_('Virtual circuit (CID)'),
386+
)
387+
virtual_circuit_id = MultiValueNumberFilter(
388+
method='filter_virtual_circuit',
389+
field_name='pk',
390+
label=_('Virtual circuit (ID)'),
391+
)
392+
provider = MultiValueCharFilter(
393+
method='filter_provider',
394+
field_name='slug',
395+
label=_('Provider (name)'),
396+
)
397+
provider_id = MultiValueNumberFilter(
398+
method='filter_provider',
399+
field_name='pk',
400+
label=_('Provider (ID)'),
388401
)
389402
group_id = django_filters.ModelMultipleChoiceFilter(
390403
queryset=CircuitGroup.objects.all(),
@@ -399,16 +412,55 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
399412

400413
class Meta:
401414
model = CircuitGroupAssignment
402-
fields = ('id', 'priority')
415+
fields = ('id', 'member_id', 'priority')
403416

404417
def search(self, queryset, name, value):
405418
if not value.strip():
406419
return queryset
407420
return queryset.filter(
408-
Q(circuit__cid__icontains=value) |
421+
Q(member__cid__icontains=value) |
409422
Q(group__name__icontains=value)
410423
)
411424

425+
def filter_circuit(self, queryset, name, value):
426+
circuits = Circuit.objects.filter(**{f'{name}__in': value})
427+
if not circuits.exists():
428+
return queryset.none()
429+
return queryset.filter(
430+
Q(
431+
member_type=ContentType.objects.get_for_model(Circuit),
432+
member_id__in=circuits
433+
)
434+
)
435+
436+
def filter_virtual_circuit(self, queryset, name, value):
437+
virtual_circuits = VirtualCircuit.objects.filter(**{f'{name}__in': value})
438+
if not virtual_circuits.exists():
439+
return queryset.none()
440+
return queryset.filter(
441+
Q(
442+
member_type=ContentType.objects.get_for_model(VirtualCircuit),
443+
member_id__in=virtual_circuits
444+
)
445+
)
446+
447+
def filter_provider(self, queryset, name, value):
448+
providers = Provider.objects.filter(**{f'{name}__in': value})
449+
if not providers.exists():
450+
return queryset.none()
451+
circuits = Circuit.objects.filter(provider__in=providers)
452+
virtual_circuits = VirtualCircuit.objects.filter(provider_network__provider__in=providers)
453+
return queryset.filter(
454+
Q(
455+
member_type=ContentType.objects.get_for_model(Circuit),
456+
member_id__in=circuits
457+
) |
458+
Q(
459+
member_type=ContentType.objects.get_for_model(VirtualCircuit),
460+
member_id__in=virtual_circuits
461+
)
462+
)
463+
412464

413465
class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
414466
provider_id = django_filters.ModelMultipleChoiceFilter(

netbox/circuits/forms/bulk_edit.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
279279

280280

281281
class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
282-
circuit = DynamicModelChoiceField(
282+
member = DynamicModelChoiceField(
283283
label=_('Circuit'),
284284
queryset=Circuit.objects.all(),
285285
required=False
@@ -292,7 +292,7 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
292292

293293
model = CircuitGroupAssignment
294294
fieldsets = (
295-
FieldSet('circuit', 'priority'),
295+
FieldSet('member', 'priority'),
296296
)
297297
nullable_fields = ('priority',)
298298

netbox/circuits/forms/bulk_import.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,19 @@ class Meta:
179179

180180

181181
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
182+
member_type = CSVContentTypeField(
183+
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
184+
label=_('Circuit type (app & model)')
185+
)
186+
priority = CSVChoiceField(
187+
label=_('Priority'),
188+
choices=CircuitPriorityChoices,
189+
required=False
190+
)
182191

183192
class Meta:
184193
model = CircuitGroupAssignment
185-
fields = ('circuit', 'group', 'priority')
194+
fields = ('member_type', 'member_id', 'group', 'priority')
186195

187196

188197
class VirtualCircuitImportForm(NetBoxModelImportForm):

netbox/circuits/forms/filtersets.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,14 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
277277
model = CircuitGroupAssignment
278278
fieldsets = (
279279
FieldSet('q', 'filter_id', 'tag'),
280-
FieldSet('provider_id', 'circuit_id', 'group_id', 'priority', name=_('Assignment')),
280+
FieldSet('provider_id', 'member_id', 'group_id', 'priority', name=_('Assignment')),
281281
)
282282
provider_id = DynamicModelMultipleChoiceField(
283283
queryset=Provider.objects.all(),
284284
required=False,
285285
label=_('Provider')
286286
)
287-
circuit_id = DynamicModelMultipleChoiceField(
287+
member_id = DynamicModelMultipleChoiceField(
288288
queryset=Circuit.objects.all(),
289289
required=False,
290290
label=_('Circuit')

netbox/circuits/forms/model_forms.py

+44-3
Original file line numberDiff line numberDiff line change
@@ -251,18 +251,59 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
251251
label=_('Group'),
252252
queryset=CircuitGroup.objects.all(),
253253
)
254-
circuit = DynamicModelChoiceField(
254+
member_type = ContentTypeChoiceField(
255+
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
256+
widget=HTMXSelect(),
257+
required=False,
258+
label=_('Circuit type')
259+
)
260+
member = DynamicModelChoiceField(
255261
label=_('Circuit'),
256-
queryset=Circuit.objects.all(),
262+
queryset=Circuit.objects.none(), # Initial queryset
263+
required=False,
264+
disabled=True,
257265
selector=True
258266
)
259267

268+
fieldsets = (
269+
FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
270+
)
271+
260272
class Meta:
261273
model = CircuitGroupAssignment
262274
fields = [
263-
'group', 'circuit', 'priority', 'tags',
275+
'group', 'member_type', 'priority', 'tags',
264276
]
265277

278+
def __init__(self, *args, **kwargs):
279+
instance = kwargs.get('instance')
280+
initial = kwargs.get('initial', {})
281+
282+
if instance is not None and instance.member:
283+
initial['member'] = instance.member
284+
kwargs['initial'] = initial
285+
286+
super().__init__(*args, **kwargs)
287+
288+
if member_type_id := get_field_value(self, 'member_type'):
289+
try:
290+
model = ContentType.objects.get(pk=member_type_id).model_class()
291+
self.fields['member'].queryset = model.objects.all()
292+
self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
293+
self.fields['member'].disabled = False
294+
self.fields['member'].label = _(bettertitle(model._meta.verbose_name))
295+
except ObjectDoesNotExist:
296+
pass
297+
298+
if self.instance.pk and member_type_id != self.instance.member_type_id:
299+
self.initial['member'] = None
300+
301+
def clean(self):
302+
super().clean()
303+
304+
# Assign the selected circuit (if any)
305+
self.instance.member = self.cleaned_data.get('member')
306+
266307

267308
class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
268309
provider_network = DynamicModelChoiceField(

netbox/circuits/graphql/types.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,18 @@ class CircuitGroupType(OrganizationalObjectType):
116116

117117
@strawberry_django.type(
118118
models.CircuitGroupAssignment,
119-
fields='__all__',
119+
exclude=('member_type', 'member_id'),
120120
filters=CircuitGroupAssignmentFilter
121121
)
122122
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
123123
group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
124-
circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
124+
125+
@strawberry_django.field
126+
def member(self) -> Annotated[Union[
127+
Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')],
128+
Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')],
129+
], strawberry.union("CircuitGroupAssignmentMemberType")] | None:
130+
return self.member
125131

126132

127133
@strawberry_django.type(

0 commit comments

Comments
 (0)