Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #18281: Support group assignment for virtual circuits #18291

Merged
merged 7 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/models/circuits/circuitgroupassignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ Circuits can be assigned to [circuit groups](./circuitgroup.md) for correlation

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

### Circuit
### Member

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

### Priority

Expand Down
3 changes: 3 additions & 0 deletions docs/release-notes/version-4.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* The `site` and `provider_network` foreign key fields on `circuits.CircuitTermination` have been replaced by the `termination` generic foreign key.
* The `site` foreign key field on `ipam.Prefix` has been replaced by the `scope` generic foreign key.
* The `site` foreign key field on `virtualization.Cluster` has been replaced by the `scope` generic foreign key.
* The `circuit` foreign key field on `circuits.CircuitGroupAssignment` has been replaced by the `member` generic foreign key.
* 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).

### New Features
Expand Down Expand Up @@ -77,6 +78,8 @@ NetBox now supports the designation of customer VLANs (CVLANs) and service VLANs
* `/api/ipam/vlan-translation-rules/`
* circuits.Circuit
* Added the optional `distance` and `distance_unit` fields
* circuits.CircuitGroupAssignment
* Replaced the `circuit` field with `member_type` and `member_id` to support virtual circuit assignment
* circuits.CircuitTermination
* Removed the `site` & `provider_network` fields
* Added the `termination_type` & `termination_id` fields to facilitate termination assignment
Expand Down
21 changes: 16 additions & 5 deletions netbox/circuits/api/serializers_/circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from rest_framework import serializers

from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.constants import CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, CIRCUIT_TERMINATION_TERMINATION_TYPES
from circuits.models import (
Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
VirtualCircuitTermination,
Expand All @@ -15,7 +15,6 @@
from netbox.choices import DistanceUnitChoices
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model

from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer

__all__ = (
Expand Down Expand Up @@ -154,14 +153,26 @@ def get_termination(self, obj):


class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
circuit = CircuitSerializer(nested=True)
member_type = ContentTypeField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS)
)
member = serializers.SerializerMethodField(read_only=True)

class Meta:
model = CircuitGroupAssignment
fields = [
'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority', 'tags',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')
brief_fields = ('id', 'url', 'display', 'group', 'member_type', 'member_id', 'member', 'priority')

@extend_schema_field(serializers.JSONField(allow_null=True))
def get_member(self, obj):
if obj.member_id is None:
return None
serializer = get_serializer_for_model(obj.member)
context = {'request': self.context['request']}
return serializer(obj.member, nested=True, context=context).data


class VirtualCircuitSerializer(NetBoxModelSerializer):
Expand Down
8 changes: 8 additions & 0 deletions netbox/circuits/constants.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
from django.db.models import Q


# models values for ContentTypes which may be CircuitTermination termination types
CIRCUIT_TERMINATION_TERMINATION_TYPES = (
'region', 'sitegroup', 'site', 'location', 'providernetwork',
)

CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS = Q(
app_label='circuits',
model__in=['circuit', 'virtualcircuit']
)
92 changes: 72 additions & 20 deletions netbox/circuits/filtersets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import django_filters
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _

Expand All @@ -7,7 +8,9 @@
from ipam.models import ASN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
)
from .choices import *
from .models import *

Expand Down Expand Up @@ -365,26 +368,36 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
method='search',
label=_('Search'),
)
provider_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider',
queryset=Provider.objects.all(),
label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label=_('Provider (slug)'),
member_type = ContentTypeFilter()
circuit = MultiValueCharFilter(
method='filter_circuit',
field_name='cid',
label=_('Circuit (CID)'),
)
circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(),
circuit_id = MultiValueNumberFilter(
method='filter_circuit',
field_name='pk',
label=_('Circuit (ID)'),
)
circuit = django_filters.ModelMultipleChoiceFilter(
field_name='circuit__cid',
queryset=Circuit.objects.all(),
to_field_name='cid',
label=_('Circuit (CID)'),
virtual_circuit = MultiValueCharFilter(
method='filter_virtual_circuit',
field_name='cid',
label=_('Virtual circuit (CID)'),
)
virtual_circuit_id = MultiValueNumberFilter(
method='filter_virtual_circuit',
field_name='pk',
label=_('Virtual circuit (ID)'),
)
provider = MultiValueCharFilter(
method='filter_provider',
field_name='slug',
label=_('Provider (name)'),
)
provider_id = MultiValueNumberFilter(
method='filter_provider',
field_name='pk',
label=_('Provider (ID)'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitGroup.objects.all(),
Expand All @@ -399,16 +412,55 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):

class Meta:
model = CircuitGroupAssignment
fields = ('id', 'priority')
fields = ('id', 'member_id', 'priority')

def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(circuit__cid__icontains=value) |
Q(member__cid__icontains=value) |
Q(group__name__icontains=value)
)

def filter_circuit(self, queryset, name, value):
circuits = Circuit.objects.filter(**{f'{name}__in': value})
if not circuits.exists():
return queryset.none()
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(Circuit),
member_id__in=circuits
)
)

def filter_virtual_circuit(self, queryset, name, value):
virtual_circuits = VirtualCircuit.objects.filter(**{f'{name}__in': value})
if not virtual_circuits.exists():
return queryset.none()
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(VirtualCircuit),
member_id__in=virtual_circuits
)
)

def filter_provider(self, queryset, name, value):
providers = Provider.objects.filter(**{f'{name}__in': value})
if not providers.exists():
return queryset.none()
circuits = Circuit.objects.filter(provider__in=providers)
virtual_circuits = VirtualCircuit.objects.filter(provider_network__provider__in=providers)
return queryset.filter(
Q(
member_type=ContentType.objects.get_for_model(Circuit),
member_id__in=circuits
) |
Q(
member_type=ContentType.objects.get_for_model(VirtualCircuit),
member_id__in=virtual_circuits
)
)


class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
Expand Down
4 changes: 2 additions & 2 deletions netbox/circuits/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):


class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
circuit = DynamicModelChoiceField(
member = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
required=False
Expand All @@ -292,7 +292,7 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):

model = CircuitGroupAssignment
fieldsets = (
FieldSet('circuit', 'priority'),
FieldSet('member', 'priority'),
)
nullable_fields = ('priority',)

Expand Down
11 changes: 10 additions & 1 deletion netbox/circuits/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,19 @@ class Meta:


class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
member_type = CSVContentTypeField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
label=_('Circuit type (app & model)')
)
priority = CSVChoiceField(
label=_('Priority'),
choices=CircuitPriorityChoices,
required=False
)

class Meta:
model = CircuitGroupAssignment
fields = ('circuit', 'group', 'priority')
fields = ('member_type', 'member_id', 'group', 'priority')


class VirtualCircuitImportForm(NetBoxModelImportForm):
Expand Down
4 changes: 2 additions & 2 deletions netbox/circuits/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,14 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
model = CircuitGroupAssignment
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'circuit_id', 'group_id', 'priority', name=_('Assignment')),
FieldSet('provider_id', 'member_id', 'group_id', 'priority', name=_('Assignment')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
required=False,
label=_('Provider')
)
circuit_id = DynamicModelMultipleChoiceField(
member_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False,
label=_('Circuit')
Expand Down
47 changes: 44 additions & 3 deletions netbox/circuits/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,18 +251,59 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
label=_('Group'),
queryset=CircuitGroup.objects.all(),
)
circuit = DynamicModelChoiceField(
member_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS),
widget=HTMXSelect(),
required=False,
label=_('Circuit type')
)
member = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
queryset=Circuit.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)

fieldsets = (
FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
)

class Meta:
model = CircuitGroupAssignment
fields = [
'group', 'circuit', 'priority', 'tags',
'group', 'member_type', 'priority', 'tags',
]

def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})

if instance is not None and instance.member:
initial['member'] = instance.member
kwargs['initial'] = initial

super().__init__(*args, **kwargs)

if member_type_id := get_field_value(self, 'member_type'):
try:
model = ContentType.objects.get(pk=member_type_id).model_class()
self.fields['member'].queryset = model.objects.all()
self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
self.fields['member'].disabled = False
self.fields['member'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass

if self.instance.pk and member_type_id != self.instance.member_type_id:
self.initial['member'] = None

def clean(self):
super().clean()

# Assign the selected circuit (if any)
self.instance.member = self.cleaned_data.get('member')


class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
provider_network = DynamicModelChoiceField(
Expand Down
10 changes: 8 additions & 2 deletions netbox/circuits/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,18 @@ class CircuitGroupType(OrganizationalObjectType):

@strawberry_django.type(
models.CircuitGroupAssignment,
fields='__all__',
exclude=('member_type', 'member_id'),
filters=CircuitGroupAssignmentFilter
)
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]

@strawberry_django.field
def member(self) -> Annotated[Union[
Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')],
Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')],
], strawberry.union("CircuitGroupAssignmentMemberType")] | None:
return self.member


@strawberry_django.type(
Expand Down
Loading
Loading