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
This commit is contained in:
Jeremy Stretch 2025-01-03 13:42:47 -05:00 committed by GitHub
parent 6f4bec7644
commit c3b0de3ebd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 403 additions and 80 deletions

View File

@ -8,9 +8,9 @@ Circuits can be assigned to [circuit groups](./circuitgroup.md) for correlation
The [circuit group](./circuitgroup.md) being assigned. 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 ### Priority

View File

@ -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` 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 `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 `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). * 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 ### New Features
@ -77,6 +78,8 @@ NetBox now supports the designation of customer VLANs (CVLANs) and service VLANs
* `/api/ipam/vlan-translation-rules/` * `/api/ipam/vlan-translation-rules/`
* circuits.Circuit * circuits.Circuit
* Added the optional `distance` and `distance_unit` fields * 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 * circuits.CircuitTermination
* Removed the `site` & `provider_network` fields * Removed the `site` & `provider_network` fields
* Added the `termination_type` & `termination_id` fields to facilitate termination assignment * Added the `termination_type` & `termination_id` fields to facilitate termination assignment

View File

@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices 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 ( from circuits.models import (
Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit, Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
VirtualCircuitTermination, VirtualCircuitTermination,
@ -15,7 +15,6 @@ from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializ
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 utilities.api import get_serializer_for_model
from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, ProviderSerializer
__all__ = ( __all__ = (
@ -154,14 +153,26 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_): 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: class Meta:
model = CircuitGroupAssignment model = CircuitGroupAssignment
fields = [ 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): class VirtualCircuitSerializer(NetBoxModelSerializer):

View File

@ -1,4 +1,12 @@
from django.db.models import Q
# models values for ContentTypes which may be CircuitTermination termination types # models values for ContentTypes which may be CircuitTermination termination types
CIRCUIT_TERMINATION_TERMINATION_TYPES = ( CIRCUIT_TERMINATION_TERMINATION_TYPES = (
'region', 'sitegroup', 'site', 'location', 'providernetwork', 'region', 'sitegroup', 'site', 'location', 'providernetwork',
) )
CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS = Q(
app_label='circuits',
model__in=['circuit', 'virtualcircuit']
)

View File

@ -1,4 +1,5 @@
import django_filters import django_filters
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -7,7 +8,9 @@ from dcim.models import Interface, 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 ContentTypeFilter, TreeNodeMultipleChoiceFilter from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
)
from .choices import * from .choices import *
from .models import * from .models import *
@ -365,26 +368,36 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
method='search', method='search',
label=_('Search'), label=_('Search'),
) )
provider_id = django_filters.ModelMultipleChoiceFilter( member_type = ContentTypeFilter()
field_name='circuit__provider', circuit = MultiValueCharFilter(
queryset=Provider.objects.all(), method='filter_circuit',
label=_('Provider (ID)'), field_name='cid',
label=_('Circuit (CID)'),
) )
provider = django_filters.ModelMultipleChoiceFilter( circuit_id = MultiValueNumberFilter(
field_name='circuit__provider__slug', method='filter_circuit',
queryset=Provider.objects.all(), field_name='pk',
to_field_name='slug',
label=_('Provider (slug)'),
)
circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(),
label=_('Circuit (ID)'), label=_('Circuit (ID)'),
) )
circuit = django_filters.ModelMultipleChoiceFilter( virtual_circuit = MultiValueCharFilter(
field_name='circuit__cid', method='filter_virtual_circuit',
queryset=Circuit.objects.all(), field_name='cid',
to_field_name='cid', label=_('Virtual circuit (CID)'),
label=_('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( group_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitGroup.objects.all(), queryset=CircuitGroup.objects.all(),
@ -399,16 +412,55 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = CircuitGroupAssignment model = CircuitGroupAssignment
fields = ('id', 'priority') fields = ('id', 'member_id', 'priority')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(circuit__cid__icontains=value) | Q(member__cid__icontains=value) |
Q(group__name__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): class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(

View File

@ -279,7 +279,7 @@ class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm): class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
circuit = DynamicModelChoiceField( member = DynamicModelChoiceField(
label=_('Circuit'), label=_('Circuit'),
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
required=False required=False
@ -292,7 +292,7 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
model = CircuitGroupAssignment model = CircuitGroupAssignment
fieldsets = ( fieldsets = (
FieldSet('circuit', 'priority'), FieldSet('member', 'priority'),
) )
nullable_fields = ('priority',) nullable_fields = ('priority',)

View File

@ -179,10 +179,19 @@ class CircuitGroupImportForm(NetBoxModelImportForm):
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm): 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: class Meta:
model = CircuitGroupAssignment model = CircuitGroupAssignment
fields = ('circuit', 'group', 'priority') fields = ('member_type', 'member_id', 'group', 'priority')
class VirtualCircuitImportForm(NetBoxModelImportForm): class VirtualCircuitImportForm(NetBoxModelImportForm):

View File

@ -277,14 +277,14 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
model = CircuitGroupAssignment model = CircuitGroupAssignment
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), 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( provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
required=False, required=False,
label=_('Provider') label=_('Provider')
) )
circuit_id = DynamicModelMultipleChoiceField( member_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
required=False, required=False,
label=_('Circuit') label=_('Circuit')

View File

@ -251,18 +251,59 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
label=_('Group'), label=_('Group'),
queryset=CircuitGroup.objects.all(), 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'), label=_('Circuit'),
queryset=Circuit.objects.all(), queryset=Circuit.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True selector=True
) )
fieldsets = (
FieldSet('group', 'member_type', 'member', 'priority', 'tags', name=_('Group Assignment')),
)
class Meta: class Meta:
model = CircuitGroupAssignment model = CircuitGroupAssignment
fields = [ 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): class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
provider_network = DynamicModelChoiceField( provider_network = DynamicModelChoiceField(

View File

@ -116,12 +116,18 @@ class CircuitGroupType(OrganizationalObjectType):
@strawberry_django.type( @strawberry_django.type(
models.CircuitGroupAssignment, models.CircuitGroupAssignment,
fields='__all__', exclude=('member_type', 'member_id'),
filters=CircuitGroupAssignmentFilter filters=CircuitGroupAssignmentFilter
) )
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')] 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( @strawberry_django.type(

View File

@ -0,0 +1,85 @@
import django.db.models.deletion
from django.db import migrations, models
def set_member_type(apps, schema_editor):
"""
Set member_type on any existing CircuitGroupAssignments to the content type for Circuit.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Circuit = apps.get_model('circuits', 'Circuit')
CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment')
CircuitGroupAssignment.objects.update(
member_type=ContentType.objects.get_for_model(Circuit)
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0050_virtual_circuits'),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0122_charfield_null_choices'),
]
operations = [
migrations.RemoveConstraint(
model_name='circuitgroupassignment',
name='circuits_circuitgroupassignment_unique_circuit_group',
),
migrations.AlterModelOptions(
name='circuitgroupassignment',
options={'ordering': ('group', 'member_type', 'member_id', 'priority', 'pk')},
),
# Change member_id to an integer field for the member GFK
migrations.RenameField(
model_name='circuitgroupassignment',
old_name='circuit',
new_name='member_id',
),
migrations.AlterField(
model_name='circuitgroupassignment',
name='member_id',
field=models.PositiveBigIntegerField(),
),
# Add content type pointer for the member GFK
migrations.AddField(
model_name='circuitgroupassignment',
name='member_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
related_name='+',
to='contenttypes.contenttype',
blank=True,
null=True
),
preserve_default=False,
),
# Populate member_type for any existing assignments
migrations.RunPython(code=set_member_type, reverse_code=migrations.RunPython.noop),
# Disallow null values for member_type
migrations.AlterField(
model_name='circuitgroupassignment',
name='member_type',
field=models.ForeignKey(
limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype'
),
),
migrations.AddConstraint(
model_name='circuitgroupassignment',
constraint=models.UniqueConstraint(
fields=('member_type', 'member_id', 'group'),
name='circuits_circuitgroupassignment_unique_member_group'
),
),
]

View File

@ -1,8 +1,7 @@
from django.apps import apps from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
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 _
@ -117,6 +116,13 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
null=True null=True
) )
group_assignments = GenericRelation(
to='circuits.CircuitGroupAssignment',
content_type_field='member_type',
object_id_field='member_id',
related_query_name='circuit'
)
clone_fields = ( clone_fields = (
'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
'description', 'description',
@ -177,15 +183,23 @@ class CircuitGroup(OrganizationalModel):
class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
""" """
Assignment of a Circuit to a CircuitGroup with an optional priority. Assignment of a physical or virtual circuit to a CircuitGroup with an optional priority.
""" """
circuit = models.ForeignKey( member_type = models.ForeignKey(
Circuit, to='contenttypes.ContentType',
on_delete=models.CASCADE, limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS,
related_name='assignments' on_delete=models.PROTECT,
related_name='+'
)
member_id = models.PositiveBigIntegerField(
verbose_name=_('member ID')
)
member = GenericForeignKey(
ct_field='member_type',
fk_field='member_id'
) )
group = models.ForeignKey( group = models.ForeignKey(
CircuitGroup, to='circuits.CircuitGroup',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='assignments' related_name='assignments'
) )
@ -197,16 +211,15 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
null=True null=True
) )
prerequisite_models = ( prerequisite_models = (
'circuits.Circuit',
'circuits.CircuitGroup', 'circuits.CircuitGroup',
) )
class Meta: class Meta:
ordering = ('group', 'circuit', 'priority', 'pk') ordering = ('group', 'member_type', 'member_id', 'priority', 'pk')
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('circuit', 'group'), fields=('member_type', 'member_id', 'group'),
name='%(app_label)s_%(class)s_unique_circuit_group' name='%(app_label)s_%(class)s_unique_member_group'
), ),
) )
verbose_name = _('Circuit group assignment') verbose_name = _('Circuit group assignment')

View File

@ -1,5 +1,6 @@
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -50,6 +51,13 @@ class VirtualCircuit(PrimaryModel):
null=True null=True
) )
group_assignments = GenericRelation(
to='circuits.CircuitGroupAssignment',
content_type_field='member_type',
object_id_field='member_id',
related_query_name='virtual_circuit'
)
clone_fields = ( clone_fields = (
'provider_network', 'provider_account', 'status', 'tenant', 'description', 'provider_network', 'provider_account', 'status', 'tenant', 'description',
) )

View File

@ -188,11 +188,14 @@ class CircuitGroupAssignmentTable(NetBoxTable):
linkify=True linkify=True
) )
provider = tables.Column( provider = tables.Column(
accessor='circuit__provider', accessor='member__provider',
verbose_name=_('Provider'), verbose_name=_('Provider'),
linkify=True linkify=True
) )
circuit = tables.Column( member_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
member = tables.Column(
verbose_name=_('Circuit'), verbose_name=_('Circuit'),
linkify=True linkify=True
) )
@ -206,6 +209,7 @@ class CircuitGroupAssignmentTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CircuitGroupAssignment model = CircuitGroupAssignment
fields = ( fields = (
'pk', 'id', 'group', 'provider', 'circuit', 'priority', 'created', 'last_updated', 'actions', 'tags', 'pk', 'id', 'group', 'provider', 'member_type', 'member', 'priority', 'created', 'last_updated', 'actions',
'tags',
) )
default_columns = ('pk', 'group', 'provider', 'circuit', 'priority') default_columns = ('pk', 'group', 'provider', 'member_type', 'member', 'priority')

View File

@ -295,7 +295,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase): class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = CircuitGroupAssignment model = CircuitGroupAssignment
brief_fields = ['circuit', 'display', 'group', 'id', 'priority', 'url'] brief_fields = ['display', 'group', 'id', 'member', 'member_id', 'member_type', 'priority', 'url']
bulk_update_data = { bulk_update_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE, 'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
} }
@ -330,17 +330,17 @@ class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
assignments = ( assignments = (
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[0], group=circuit_groups[0],
circuit=circuits[0], member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY priority=CircuitPriorityChoices.PRIORITY_PRIMARY
), ),
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[1], group=circuit_groups[1],
circuit=circuits[1], member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY priority=CircuitPriorityChoices.PRIORITY_SECONDARY
), ),
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[2], group=circuit_groups[2],
circuit=circuits[2], member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY priority=CircuitPriorityChoices.PRIORITY_TERTIARY
), ),
) )
@ -349,17 +349,20 @@ class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
cls.create_data = [ cls.create_data = [
{ {
'group': circuit_groups[3].pk, 'group': circuit_groups[3].pk,
'circuit': circuits[3].pk, 'member_type': 'circuits.circuit',
'member_id': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_PRIMARY, 'priority': CircuitPriorityChoices.PRIORITY_PRIMARY,
}, },
{ {
'group': circuit_groups[4].pk, 'group': circuit_groups[4].pk,
'circuit': circuits[4].pk, 'member_type': 'circuits.circuit',
'member_id': circuits[4].pk,
'priority': CircuitPriorityChoices.PRIORITY_SECONDARY, 'priority': CircuitPriorityChoices.PRIORITY_SECONDARY,
}, },
{ {
'group': circuit_groups[5].pk, 'group': circuit_groups[5].pk,
'circuit': circuits[5].pk, 'member_type': 'circuits.circuit',
'member_id': circuits[5].pk,
'priority': CircuitPriorityChoices.PRIORITY_TERTIARY, 'priority': CircuitPriorityChoices.PRIORITY_TERTIARY,
}, },
] ]

View File

@ -648,7 +648,6 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'), CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'), CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'), CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
) )
CircuitGroup.objects.bulk_create(circuit_groups) CircuitGroup.objects.bulk_create(circuit_groups)
@ -656,7 +655,6 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
Provider(name='Provider 1', slug='provider-1'), Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'), Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'), Provider(name='Provider 3', slug='provider-3'),
Provider(name='Provider 4', slug='provider-4'),
)) ))
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
@ -664,35 +662,72 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit(cid='Circuit 1', provider=providers[0], type=circuittype), Circuit(cid='Circuit 1', provider=providers[0], type=circuittype),
Circuit(cid='Circuit 2', provider=providers[1], type=circuittype), Circuit(cid='Circuit 2', provider=providers[1], type=circuittype),
Circuit(cid='Circuit 3', provider=providers[2], type=circuittype), Circuit(cid='Circuit 3', provider=providers[2], type=circuittype),
Circuit(cid='Circuit 4', provider=providers[3], type=circuittype),
) )
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
)
ProviderNetwork.objects.bulk_create(provider_networks)
virtual_circuits = (
VirtualCircuit(
provider_network=provider_networks[0],
cid='Virtual Circuit 1'
),
VirtualCircuit(
provider_network=provider_networks[1],
cid='Virtual Circuit 2'
),
VirtualCircuit(
provider_network=provider_networks[2],
cid='Virtual Circuit 3'
),
)
VirtualCircuit.objects.bulk_create(virtual_circuits)
assignments = ( assignments = (
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[0], group=circuit_groups[0],
circuit=circuits[0], member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY priority=CircuitPriorityChoices.PRIORITY_PRIMARY
), ),
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[1], group=circuit_groups[1],
circuit=circuits[1], member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY priority=CircuitPriorityChoices.PRIORITY_SECONDARY
), ),
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[2], group=circuit_groups[2],
circuit=circuits[2], member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
CircuitGroupAssignment(
group=circuit_groups[0],
member=virtual_circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
member=virtual_circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
member=virtual_circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY priority=CircuitPriorityChoices.PRIORITY_TERTIARY
), ),
) )
CircuitGroupAssignment.objects.bulk_create(assignments) CircuitGroupAssignment.objects.bulk_create(assignments)
def test_group_id(self): def test_group(self):
groups = CircuitGroup.objects.filter(name__in=['Circuit Group 1', 'Circuit Group 2']) groups = CircuitGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]} params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'group': [groups[0].slug, groups[1].slug]} params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_circuit(self): def test_circuit(self):
circuits = Circuit.objects.all()[:2] circuits = Circuit.objects.all()[:2]
@ -701,12 +736,19 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'circuit': [circuits[0].cid, circuits[1].cid]} params = {'circuit': [circuits[0].cid, circuits[1].cid]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_circuit(self):
virtual_circuits = VirtualCircuit.objects.all()[:2]
params = {'virtual_circuit_id': [virtual_circuits[0].pk, virtual_circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_circuit': [virtual_circuits[0].cid, virtual_circuits[1].cid]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider(self): def test_provider(self):
providers = Provider.objects.all()[:2] providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]} params = {'provider_id': [providers[0].pk, providers[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'provider': [providers[0].slug, providers[1].slug]} params = {'provider': [providers[0].slug, providers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests): class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@ -468,6 +468,7 @@ class CircuitGroupAssignmentTestCase(
ViewTestCases.DeleteObjectViewTestCase, ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase, ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase, ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase ViewTestCases.BulkDeleteObjectsViewTestCase
): ):
model = CircuitGroupAssignment model = CircuitGroupAssignment
@ -497,17 +498,17 @@ class CircuitGroupAssignmentTestCase(
assignments = ( assignments = (
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[0], group=circuit_groups[0],
circuit=circuits[0], member=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY priority=CircuitPriorityChoices.PRIORITY_PRIMARY
), ),
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[1], group=circuit_groups[1],
circuit=circuits[1], member=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY priority=CircuitPriorityChoices.PRIORITY_SECONDARY
), ),
CircuitGroupAssignment( CircuitGroupAssignment(
group=circuit_groups[2], group=circuit_groups[2],
circuit=circuits[2], member=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY priority=CircuitPriorityChoices.PRIORITY_TERTIARY
), ),
) )
@ -517,11 +518,26 @@ class CircuitGroupAssignmentTestCase(
cls.form_data = { cls.form_data = {
'group': circuit_groups[3].pk, 'group': circuit_groups[3].pk,
'circuit': circuits[3].pk, 'member_type': ContentType.objects.get_for_model(Circuit).pk,
'member': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE, 'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
cls.csv_data = (
"member_type,member_id,group,priority",
f"circuits.circuit,{circuits[0].pk},{circuit_groups[3].pk},primary",
f"circuits.circuit,{circuits[1].pk},{circuit_groups[3].pk},secondary",
f"circuits.circuit,{circuits[2].pk},{circuit_groups[3].pk},tertiary",
)
cls.csv_update_data = (
"id,priority",
f"{assignments[0].pk},inactive",
f"{assignments[1].pk},inactive",
f"{assignments[2].pk},inactive",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE, 'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
} }

View File

@ -279,8 +279,6 @@ CIRCUITS_MENU = Menu(
items=( items=(
get_model_item('circuits', 'circuit', _('Circuits')), get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')), get_model_item('circuits', 'circuittype', _('Circuit Types')),
get_model_item('circuits', 'circuitgroup', _('Circuit Groups')),
get_model_item('circuits', 'circuitgroupassignment', _('Group Assignments')),
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')), get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
), ),
), ),
@ -291,6 +289,13 @@ CIRCUITS_MENU = Menu(
get_model_item('circuits', 'virtualcircuittermination', _('Virtual Circuit Terminations')), get_model_item('circuits', 'virtualcircuittermination', _('Virtual Circuit Terminations')),
), ),
), ),
MenuGroup(
label=_('Groups'),
items=(
get_model_item('circuits', 'circuitgroup', _('Circuit Groups')),
get_model_item('circuits', 'circuitgroupassignment', _('Group Assignments')),
),
),
MenuGroup( MenuGroup(
label=_('Providers'), label=_('Providers'),
items=( items=(

View File

@ -76,7 +76,7 @@
{% trans "Group Assignments" %} {% trans "Group Assignments" %}
{% if perms.circuits.add_circuitgroupassignment %} {% if perms.circuits.add_circuitgroupassignment %}
<div class="card-actions"> <div class="card-actions">
<a href="{% url 'circuits:circuitgroupassignment_add' %}?circuit={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm"> <a href="{% url 'circuits:circuitgroupassignment_add' %}?member_type={{ object|content_type_id }}&member={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %} <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
</a> </a>
</div> </div>

View File

@ -22,9 +22,13 @@
<th scope="row">{% trans "Group" %}</th> <th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify }}</td> <td>{{ object.group|linkify }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.member.provider|linkify }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Circuit" %}</th> <th scope="row">{% trans "Circuit" %}</th>
<td>{{ object.circuit|linkify }}</td> <td>{{ object.member|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Priority" %}</th> <th scope="row">{% trans "Priority" %}</th>

View File

@ -60,6 +60,19 @@
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %} {% include 'inc/panels/comments.html' %}
<div class="card">
<h2 class="card-header">
{% trans "Group Assignments" %}
{% if perms.circuits.add_circuitgroupassignment %}
<div class="card-actions">
<a href="{% url 'circuits:circuitgroupassignment_add' %}?member_type={{ object|content_type_id }}&member={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'circuits:circuitgroupassignment_list' virtual_circuit_id=object.pk %}
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>