فهرست منبع

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 سال پیش
والد
کامیت
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.
 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
 
 

+ 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` 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

+ 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 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):

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

+ 72 - 20
netbox/circuits/filtersets.py

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

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

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

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

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

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

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

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

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

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

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

+ 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.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(
-        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(
     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'),
-                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')
         verbose_name = _('Circuit group assignment')

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

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

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

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

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

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

+ 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 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):
-        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]}
         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):

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

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

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

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

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

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

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

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

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

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