diff --git a/docs/models/circuits/circuitgroupassignment.md b/docs/models/circuits/circuitgroupassignment.md index 2aaa375af..c73ba09be 100644 --- a/docs/models/circuits/circuitgroupassignment.md +++ b/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 diff --git a/docs/release-notes/version-4.2.md b/docs/release-notes/version-4.2.md index fe138c064..f0ad3766c 100644 --- a/docs/release-notes/version-4.2.md +++ b/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 diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py index 0365dca33..4f3dc5f35 100644 --- a/netbox/circuits/api/serializers_/circuits.py +++ b/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): diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py index 8119bc286..4642c22a3 100644 --- a/netbox/circuits/constants.py +++ b/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'] +) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 825df9558..956e91d76 100644 --- a/netbox/circuits/filtersets.py +++ b/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)'), + member_type = ContentTypeFilter() + circuit = MultiValueCharFilter( + method='filter_circuit', + field_name='cid', + label=_('Circuit (CID)'), ) - provider = django_filters.ModelMultipleChoiceFilter( - field_name='circuit__provider__slug', - queryset=Provider.objects.all(), - to_field_name='slug', - label=_('Provider (slug)'), - ) - 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( diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 021635a1a..b8e6094f9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/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',) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 7f5ffde6e..428c636b3 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/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): diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 47ce24d97..1359b7a6a 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/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') diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index e43c37525..94ab4db78 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/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( diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index f2703b207..1cffc8cd4 100644 --- a/netbox/circuits/graphql/types.py +++ b/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( diff --git a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py new file mode 100644 index 000000000..f8c0fd653 --- /dev/null +++ b/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' + ), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 5e910b5d5..8a8e4bdf9 100644 --- a/netbox/circuits/models/circuits.py +++ b/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') diff --git a/netbox/circuits/models/virtual_circuits.py b/netbox/circuits/models/virtual_circuits.py index ced68c105..04255cd0d 100644 --- a/netbox/circuits/models/virtual_circuits.py +++ b/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', ) diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index dedb1534b..ed9ecde2e 100644 --- a/netbox/circuits/tables/circuits.py +++ b/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') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 92fbbdedf..291a2596c 100644 --- a/netbox/circuits/tests/test_api.py +++ b/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, }, ] diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 6b7866665..d9a5c1bc2 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/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): diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 036bebe23..56f874c1a 100644 --- a/netbox/circuits/tests/test_views.py +++ b/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, } diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index fbbd3d0b1..559c2860b 100644 --- a/netbox/netbox/navigation/menu.py +++ b/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=( diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 52c7d9894..d47b380fd 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -76,7 +76,7 @@ {% trans "Group Assignments" %} {% if perms.circuits.add_circuitgroupassignment %}
diff --git a/netbox/templates/circuits/circuitgroupassignment.html b/netbox/templates/circuits/circuitgroupassignment.html index bd277a5fc..f1bb9f5d4 100644 --- a/netbox/templates/circuits/circuitgroupassignment.html +++ b/netbox/templates/circuits/circuitgroupassignment.html @@ -22,9 +22,13 @@