Просмотр исходного кода

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
Jeremy Stretch 1 год назад
Родитель
Сommit
c3b0de3ebd

+ 2 - 2
docs/models/circuits/circuitgroupassignment.md

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

+ 3 - 0
docs/release-notes/version-4.2.md

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

+ 16 - 5
netbox/circuits/api/serializers_/circuits.py

@@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field
 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,
@@ -15,7 +15,6 @@ from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializ
 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__ = (
@@ -154,14 +153,26 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
 
 
 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):

+ 8 - 0
netbox/circuits/constants.py

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

+ 72 - 20
netbox/circuits/filtersets.py

@@ -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 _
 
@@ -7,7 +8,9 @@ from dcim.models import Interface, Location, Region, Site, SiteGroup
 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 *
 
@@ -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(),
@@ -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(

+ 2 - 2
netbox/circuits/forms/bulk_edit.py

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

+ 10 - 1
netbox/circuits/forms/bulk_import.py

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

+ 2 - 2
netbox/circuits/forms/filtersets.py

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

+ 44 - 3
netbox/circuits/forms/model_forms.py

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

+ 8 - 2
netbox/circuits/graphql/types.py

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

+ 85 - 0
netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py

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

+ 25 - 12
netbox/circuits/models/circuits.py

@@ -1,8 +1,7 @@
 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.db import models
-from django.db.models import Q
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
@@ -117,6 +116,13 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
         null=True
     )
 
+    group_assignments = GenericRelation(
+        to='circuits.CircuitGroupAssignment',
+        content_type_field='member_type',
+        object_id_field='member_id',
+        related_query_name='circuit'
+    )
+
     clone_fields = (
         'provider', 'provider_account', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
         'description',
@@ -177,15 +183,23 @@ class CircuitGroup(OrganizationalModel):
 
 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(
-        Circuit,
-        on_delete=models.CASCADE,
-        related_name='assignments'
+    member_type = models.ForeignKey(
+        to='contenttypes.ContentType',
+        limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS,
+        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(
-        CircuitGroup,
+        to='circuits.CircuitGroup',
         on_delete=models.CASCADE,
         related_name='assignments'
     )
@@ -197,16 +211,15 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
         null=True
     )
     prerequisite_models = (
-        'circuits.Circuit',
         'circuits.CircuitGroup',
     )
 
     class Meta:
-        ordering = ('group', 'circuit', 'priority', 'pk')
+        ordering = ('group', 'member_type', 'member_id', 'priority', 'pk')
         constraints = (
             models.UniqueConstraint(
-                fields=('circuit', 'group'),
-                name='%(app_label)s_%(class)s_unique_circuit_group'
+                fields=('member_type', 'member_id', 'group'),
+                name='%(app_label)s_%(class)s_unique_member_group'
             ),
         )
         verbose_name = _('Circuit group assignment')

+ 8 - 0
netbox/circuits/models/virtual_circuits.py

@@ -1,5 +1,6 @@
 from functools import cached_property
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
@@ -50,6 +51,13 @@ class VirtualCircuit(PrimaryModel):
         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 = (
         'provider_network', 'provider_account', 'status', 'tenant', 'description',
     )

+ 8 - 4
netbox/circuits/tables/circuits.py

@@ -188,11 +188,14 @@ class CircuitGroupAssignmentTable(NetBoxTable):
         linkify=True
     )
     provider = tables.Column(
-        accessor='circuit__provider',
+        accessor='member__provider',
         verbose_name=_('Provider'),
         linkify=True
     )
-    circuit = tables.Column(
+    member_type = columns.ContentTypeColumn(
+        verbose_name=_('Type')
+    )
+    member = tables.Column(
         verbose_name=_('Circuit'),
         linkify=True
     )
@@ -206,6 +209,7 @@ class CircuitGroupAssignmentTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = CircuitGroupAssignment
         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')

+ 10 - 7
netbox/circuits/tests/test_api.py

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

+ 54 - 12
netbox/circuits/tests/test_filtersets.py

@@ -648,7 +648,6 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
             CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
             CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
             CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
-            CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
         )
         CircuitGroup.objects.bulk_create(circuit_groups)
 
@@ -656,7 +655,6 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
             Provider(name='Provider 1', slug='provider-1'),
             Provider(name='Provider 2', slug='provider-2'),
             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')
 
@@ -664,35 +662,72 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
             Circuit(cid='Circuit 1', provider=providers[0], type=circuittype),
             Circuit(cid='Circuit 2', provider=providers[1], 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)
 
+        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 = (
             CircuitGroupAssignment(
                 group=circuit_groups[0],
-                circuit=circuits[0],
+                member=circuits[0],
                 priority=CircuitPriorityChoices.PRIORITY_PRIMARY
             ),
             CircuitGroupAssignment(
                 group=circuit_groups[1],
-                circuit=circuits[1],
+                member=circuits[1],
                 priority=CircuitPriorityChoices.PRIORITY_SECONDARY
             ),
             CircuitGroupAssignment(
                 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
             ),
         )
         CircuitGroupAssignment.objects.bulk_create(assignments)
 
-    def test_group_id(self):
-        groups = CircuitGroup.objects.filter(name__in=['Circuit Group 1', 'Circuit Group 2'])
+    def test_group(self):
+        groups = CircuitGroup.objects.all()[:2]
         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]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_circuit(self):
         circuits = Circuit.objects.all()[:2]
@@ -701,12 +736,19 @@ class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'circuit': [circuits[0].cid, circuits[1].cid]}
         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):
         providers = Provider.objects.all()[:2]
         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]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):

+ 20 - 4
netbox/circuits/tests/test_views.py

@@ -468,6 +468,7 @@ class CircuitGroupAssignmentTestCase(
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.ListObjectsViewTestCase,
     ViewTestCases.BulkEditObjectsViewTestCase,
+    ViewTestCases.BulkImportObjectsViewTestCase,
     ViewTestCases.BulkDeleteObjectsViewTestCase
 ):
     model = CircuitGroupAssignment
@@ -497,17 +498,17 @@ class CircuitGroupAssignmentTestCase(
         assignments = (
             CircuitGroupAssignment(
                 group=circuit_groups[0],
-                circuit=circuits[0],
+                member=circuits[0],
                 priority=CircuitPriorityChoices.PRIORITY_PRIMARY
             ),
             CircuitGroupAssignment(
                 group=circuit_groups[1],
-                circuit=circuits[1],
+                member=circuits[1],
                 priority=CircuitPriorityChoices.PRIORITY_SECONDARY
             ),
             CircuitGroupAssignment(
                 group=circuit_groups[2],
-                circuit=circuits[2],
+                member=circuits[2],
                 priority=CircuitPriorityChoices.PRIORITY_TERTIARY
             ),
         )
@@ -517,11 +518,26 @@ class CircuitGroupAssignmentTestCase(
 
         cls.form_data = {
             '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,
             '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 = {
             'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
         }

+ 7 - 2
netbox/netbox/navigation/menu.py

@@ -279,8 +279,6 @@ CIRCUITS_MENU = Menu(
             items=(
                 get_model_item('circuits', 'circuit', _('Circuits')),
                 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')),
             ),
         ),
@@ -291,6 +289,13 @@ CIRCUITS_MENU = Menu(
                 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(
             label=_('Providers'),
             items=(

+ 1 - 1
netbox/templates/circuits/circuit.html

@@ -76,7 +76,7 @@
           {% trans "Group Assignments" %}
           {% if perms.circuits.add_circuitgroupassignment %}
             <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" %}
               </a>
             </div>

+ 5 - 1
netbox/templates/circuits/circuitgroupassignment.html

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

+ 13 - 0
netbox/templates/circuits/virtualcircuit.html

@@ -60,6 +60,19 @@
     <div class="col col-md-6">
       {% include 'inc/panels/custom_fields.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 %}
     </div>
   </div>