Explorar o código

7025 circuit redundancy (#16945)

* 7025 CircuitRedundancyGroups

* 7025 CircuitRedundancyGroups api

* 7025 CircuitRedundancyGroups api

* 7025 CircuitRedundancyGroups tests

* 7025 CircuitRedundancyGroup -> CircuitGroup

* 7025 add tenancy

* 7025 linkify name

* 7025 missing file

* 7025 circuitgroupassignment

* 7025 base group assignment working

* 7025 assignments

* 7025 fix forms/tests for CircuitGroup

* 7025 fix api tests

* 7025 view tests

* 7025 CircuitGroupAssignment tests

* 7025 fix typo

* 7025 fix typo

* 7025 fix tests

* 7025 remove m2m

* 7025 add count to serializer

* 7025 fix test

* 7025 documentation

* 7025 review comments

* 7025 review comments

* 7025 add search index

* Make CircuitPriorityChoices extensible

* Add group assignment table to circuit view

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson hai 1 ano
pai
achega
8237c6accc

+ 13 - 0
docs/models/circuits/circuitgroup.md

@@ -0,0 +1,13 @@
+# Circuit Groups
+
+[Circuits](./circuit.md) can be arranged into administrative groups for organization. The assignment of a circuit to a group is optional.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)

+ 25 - 0
docs/models/circuits/circuitgroupassignment.md

@@ -0,0 +1,25 @@
+# Circuit Group Assignments
+
+Circuits can be assigned to [circuit groups](./circuitgroup.md) for correlation purposes. For instance, three circuits, each belonging to a different provider, may each be assigned to the same circuit group. Each assignment may optionally include a priority designation.
+
+## Fields
+
+### Group
+
+The [circuit group](./circuitgroup.md) being assigned.
+
+### Circuit
+
+The [circuit](./circuit.md) that is being assigned to the group.
+
+### Priority
+
+The circuit's operation priority relative to its peers within the group. The assignment of a priority is optional. Choices include:
+
+* Primary
+* Secondary
+* Tertiary
+* Inactive
+
+!!! tip
+    Additional priority choices may be defined by setting `CircuitGroupAssignment.priority` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.

+ 2 - 0
mkdocs.yml

@@ -164,6 +164,8 @@ nav:
     - Data Model:
         - Circuits:
             - Circuit: 'models/circuits/circuit.md'
+            - CircuitGroup: 'models/circuits/circuitgroup.md'
+            - CircuitGroupAssignment: 'models/circuits/circuitgroupassignment.md'
             - Circuit Termination: 'models/circuits/circuittermination.md'
             - Circuit Type: 'models/circuits/circuittype.md'
             - Provider: 'models/circuits/provider.md'

+ 45 - 3
netbox/circuits/api/serializers_/circuits.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 
-from circuits.choices import CircuitStatusChoices
-from circuits.models import Circuit, CircuitTermination, CircuitType
+from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices
+from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
 from dcim.api.serializers_.cables import CabledObjectSerializer
 from dcim.api.serializers_.sites import SiteSerializer
 from netbox.api.fields import ChoiceField, RelatedObjectCountField
@@ -12,6 +12,8 @@ from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, Pro
 
 __all__ = (
     'CircuitSerializer',
+    'CircuitGroupAssignmentSerializer',
+    'CircuitGroupSerializer',
     'CircuitTerminationSerializer',
     'CircuitTypeSerializer',
 )
@@ -43,6 +45,34 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
         ]
 
 
+class CircuitGroupSerializer(NetBoxModelSerializer):
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+    circuit_count = RelatedObjectCountField('assignments')
+
+    class Meta:
+        model = CircuitGroup
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant',
+            'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count'
+        ]
+        brief_fields = ('id', 'url', 'display', 'name')
+
+
+class CircuitGroupAssignmentSerializer_(NetBoxModelSerializer):
+    """
+    Base serializer for group assignments under CircuitSerializer.
+    """
+    group = CircuitGroupSerializer(nested=True)
+    priority = ChoiceField(choices=CircuitPriorityChoices, allow_blank=True, required=False)
+
+    class Meta:
+        model = CircuitGroupAssignment
+        fields = [
+            'id', 'url', 'display_url', 'display', 'group', 'priority', 'tags', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'group', 'priority')
+
+
 class CircuitSerializer(NetBoxModelSerializer):
     provider = ProviderSerializer(nested=True)
     provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
@@ -51,13 +81,14 @@ class CircuitSerializer(NetBoxModelSerializer):
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
     termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
+    assignments = CircuitGroupAssignmentSerializer_(nested=True, many=True, required=False)
 
     class Meta:
         model = Circuit
         fields = [
             'id', 'url', 'display_url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant',
             'install_date', 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'assignments',
         ]
         brief_fields = ('id', 'url', 'display', 'cid', 'description')
 
@@ -75,3 +106,14 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
             'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
+
+
+class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
+    circuit = CircuitSerializer(nested=True)
+
+    class Meta:
+        model = CircuitGroupAssignment
+        fields = [
+            'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')

+ 2 - 0
netbox/circuits/api/urls.py

@@ -14,6 +14,8 @@ router.register('provider-networks', views.ProviderNetworkViewSet)
 router.register('circuit-types', views.CircuitTypeViewSet)
 router.register('circuits', views.CircuitViewSet)
 router.register('circuit-terminations', views.CircuitTerminationViewSet)
+router.register('circuit-groups', views.CircuitGroupViewSet)
+router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet)
 
 app_name = 'circuits-api'
 urlpatterns = router.urls

+ 20 - 0
netbox/circuits/api/views.py

@@ -55,6 +55,26 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
     filterset_class = filtersets.CircuitTerminationFilterSet
 
 
+#
+# Circuit Groups
+#
+
+class CircuitGroupViewSet(NetBoxModelViewSet):
+    queryset = CircuitGroup.objects.all()
+    serializer_class = serializers.CircuitGroupSerializer
+    filterset_class = filtersets.CircuitGroupFilterSet
+
+
+#
+# Circuit Group Assignments
+#
+
+class CircuitGroupAssignmentViewSet(NetBoxModelViewSet):
+    queryset = CircuitGroupAssignment.objects.all()
+    serializer_class = serializers.CircuitGroupAssignmentSerializer
+    filterset_class = filtersets.CircuitGroupAssignmentFilterSet
+
+
 #
 # Provider accounts
 #

+ 16 - 0
netbox/circuits/choices.py

@@ -76,3 +76,19 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
         (1544, 'T1 (1.544 Mbps)'),
         (2048, 'E1 (2.048 Mbps)'),
     ]
+
+
+class CircuitPriorityChoices(ChoiceSet):
+    key = 'CircuitGroupAssignment.priority'
+
+    PRIORITY_PRIMARY = 'primary'
+    PRIORITY_SECONDARY = 'secondary'
+    PRIORITY_TERTIARY = 'tertiary'
+    PRIORITY_INACTIVE = 'inactive'
+
+    CHOICES = [
+        (PRIORITY_PRIMARY, _('Primary')),
+        (PRIORITY_SECONDARY, _('Secondary')),
+        (PRIORITY_TERTIARY, _('Tertiary')),
+        (PRIORITY_INACTIVE, _('Inactive')),
+    ]

+ 42 - 0
netbox/circuits/filtersets.py

@@ -13,6 +13,8 @@ from .models import *
 
 __all__ = (
     'CircuitFilterSet',
+    'CircuitGroupAssignmentFilterSet',
+    'CircuitGroupFilterSet',
     'CircuitTerminationFilterSet',
     'CircuitTypeFilterSet',
     'ProviderNetworkFilterSet',
@@ -303,3 +305,43 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
             Q(pp_info__icontains=value) |
             Q(description__icontains=value)
         ).distinct()
+
+
+class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
+
+    class Meta:
+        model = CircuitGroup
+        fields = ('id', 'name', 'slug', 'description')
+
+
+class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+    circuit_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Circuit.objects.all(),
+        label=_('Circuit'),
+    )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CircuitGroup.objects.all(),
+        label=_('Circuit group (ID)'),
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        field_name='group__slug',
+        queryset=CircuitGroup.objects.all(),
+        to_field_name='slug',
+        label=_('Circuit group (slug)'),
+    )
+
+    class Meta:
+        model = CircuitGroupAssignment
+        fields = ('id', 'circuit', 'group', 'priority')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(circuit__cid__icontains=value) |
+            Q(group__name__icontains=value)
+        )

+ 40 - 1
netbox/circuits/forms/bulk_edit.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
-from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
+from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
 from circuits.models import *
 from dcim.models import Site
 from ipam.models import ASN
@@ -14,6 +14,8 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, Numbe
 
 __all__ = (
     'CircuitBulkEditForm',
+    'CircuitGroupAssignmentBulkEditForm',
+    'CircuitGroupBulkEditForm',
     'CircuitTerminationBulkEditForm',
     'CircuitTypeBulkEditForm',
     'ProviderBulkEditForm',
@@ -219,3 +221,40 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
         FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
     )
     nullable_fields = ('description')
+
+
+class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    tenant = DynamicModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+
+    model = CircuitGroup
+    nullable_fields = (
+        'description', 'tenant',
+    )
+
+
+class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
+    circuit = DynamicModelChoiceField(
+        label=_('Circuit'),
+        queryset=Circuit.objects.all(),
+        required=False
+    )
+    priority = forms.ChoiceField(
+        label=_('Priority'),
+        choices=add_blank_choice(CircuitPriorityChoices),
+        required=False
+    )
+
+    model = CircuitGroupAssignment
+    fieldsets = (
+        FieldSet('circuit', 'priority'),
+    )
+    nullable_fields = ('priority',)

+ 23 - 0
netbox/circuits/forms/bulk_import.py

@@ -11,6 +11,8 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
 
 __all__ = (
     'CircuitImportForm',
+    'CircuitGroupAssignmentImportForm',
+    'CircuitGroupImportForm',
     'CircuitTerminationImportForm',
     'CircuitTerminationImportRelatedForm',
     'CircuitTypeImportForm',
@@ -150,3 +152,24 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination
             'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
             'pp_info', 'description', 'tags'
         ]
+
+
+class CircuitGroupImportForm(NetBoxModelImportForm):
+    tenant = CSVModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Assigned tenant')
+    )
+
+    class Meta:
+        model = CircuitGroup
+        fields = ('name', 'slug', 'description', 'tenant', 'tags')
+
+
+class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
+
+    class Meta:
+        model = CircuitGroupAssignment
+        fields = ('circuit', 'group', 'priority')

+ 36 - 1
netbox/circuits/forms/filtersets.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.utils.translation import gettext as _
 
-from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices
+from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices
 from circuits.models import *
 from dcim.models import Region, Site, SiteGroup
 from ipam.models import ASN
@@ -13,6 +13,8 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 __all__ = (
     'CircuitFilterForm',
+    'CircuitGroupAssignmentFilterForm',
+    'CircuitGroupFilterForm',
     'CircuitTerminationFilterForm',
     'CircuitTypeFilterForm',
     'ProviderFilterForm',
@@ -230,3 +232,36 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
         label=_('Provider')
     )
     tag = TagFilterField(model)
+
+
+class CircuitGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+    model = CircuitGroup
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+    )
+    tag = TagFilterField(model)
+
+
+class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
+    model = CircuitGroupAssignment
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('circuit_id', 'group_id', 'priority', name=_('Assignment')),
+    )
+    circuit_id = DynamicModelMultipleChoiceField(
+        queryset=Circuit.objects.all(),
+        required=False,
+        label=_('Circuit')
+    )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=CircuitGroup.objects.all(),
+        required=False,
+        label=_('Group')
+    )
+    priority = forms.MultipleChoiceField(
+        label=_('Priority'),
+        choices=CircuitPriorityChoices,
+        required=False
+    )
+    tag = TagFilterField(model)

+ 34 - 0
netbox/circuits/forms/model_forms.py

@@ -12,6 +12,8 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
 
 __all__ = (
     'CircuitForm',
+    'CircuitGroupAssignmentForm',
+    'CircuitGroupForm',
     'CircuitTerminationForm',
     'CircuitTypeForm',
     'ProviderForm',
@@ -171,3 +173,35 @@ class CircuitTerminationForm(NetBoxModelForm):
                 options=CircuitTerminationPortSpeedChoices
             ),
         }
+
+
+class CircuitGroupForm(TenancyForm, NetBoxModelForm):
+    slug = SlugField()
+
+    fieldsets = (
+        FieldSet('name', 'slug', 'description', 'tags', name=_('Circuit Group')),
+        FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
+    )
+
+    class Meta:
+        model = CircuitGroup
+        fields = [
+            'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
+        ]
+
+
+class CircuitGroupAssignmentForm(NetBoxModelForm):
+    group = DynamicModelChoiceField(
+        label=_('Group'),
+        queryset=CircuitGroup.objects.all(),
+    )
+    circuit = DynamicModelChoiceField(
+        label=_('Circuit'),
+        queryset=Circuit.objects.all(),
+    )
+
+    class Meta:
+        model = CircuitGroupAssignment
+        fields = [
+            'group', 'circuit', 'priority', 'tags',
+        ]

+ 14 - 0
netbox/circuits/graphql/filters.py

@@ -7,6 +7,8 @@ from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 __all__ = (
     'CircuitTerminationFilter',
     'CircuitFilter',
+    'CircuitGroupAssignmentFilter',
+    'CircuitGroupFilter',
     'CircuitTypeFilter',
     'ProviderFilter',
     'ProviderAccountFilter',
@@ -32,6 +34,18 @@ class CircuitTypeFilter(BaseFilterMixin):
     pass
 
 
+@strawberry_django.filter(models.CircuitGroup, lookups=True)
+@autotype_decorator(filtersets.CircuitGroupFilterSet)
+class CircuitGroupFilter(BaseFilterMixin):
+    pass
+
+
+@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True)
+@autotype_decorator(filtersets.CircuitGroupAssignmentFilterSet)
+class CircuitGroupAssignmentFilter(BaseFilterMixin):
+    pass
+
+
 @strawberry_django.filter(models.Provider, lookups=True)
 @autotype_decorator(filtersets.ProviderFilterSet)
 class ProviderFilter(BaseFilterMixin):

+ 10 - 0
netbox/circuits/graphql/schema.py

@@ -24,6 +24,16 @@ class CircuitsQuery:
         return models.CircuitType.objects.get(pk=id)
     circuit_type_list: List[CircuitTypeType] = strawberry_django.field()
 
+    @strawberry.field
+    def circuit_group(self, id: int) -> CircuitGroupType:
+        return models.CircuitGroup.objects.get(pk=id)
+    circuit_group_list: List[CircuitGroupType] = strawberry_django.field()
+
+    @strawberry.field
+    def circuit_group_assignment(self, id: int) -> CircuitGroupAssignmentType:
+        return models.CircuitGroupAssignment.objects.get(pk=id)
+    circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field()
+
     @strawberry.field
     def provider(self, id: int) -> ProviderType:
         return models.Provider.objects.get(pk=id)

+ 22 - 1
netbox/circuits/graphql/types.py

@@ -6,13 +6,15 @@ import strawberry_django
 from circuits import models
 from dcim.graphql.mixins import CabledObjectMixin
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
-from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType
+from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, OrganizationalObjectType
 from tenancy.graphql.types import TenantType
 from .filters import *
 
 __all__ = (
     'CircuitTerminationType',
     'CircuitType',
+    'CircuitGroupAssignmentType',
+    'CircuitGroupType',
     'CircuitTypeType',
     'ProviderType',
     'ProviderAccountType',
@@ -91,3 +93,22 @@ class CircuitType(NetBoxObjectType, ContactsMixin):
     tenant: TenantType | None
 
     terminations: List[CircuitTerminationType]
+
+
+@strawberry_django.type(
+    models.CircuitGroup,
+    fields='__all__',
+    filters=CircuitGroupFilter
+)
+class CircuitGroupType(OrganizationalObjectType):
+    tenant: TenantType | None
+
+
+@strawberry_django.type(
+    models.CircuitGroupAssignment,
+    fields='__all__',
+    filters=CircuitGroupAssignmentFilter
+)
+class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
+    group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
+    circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]

+ 90 - 0
netbox/circuits/migrations/0044_circuitgroup_circuitgroupassignment_and_more.py

@@ -0,0 +1,90 @@
+# Generated by Django 5.0.7 on 2024-07-22 06:27
+
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0043_circuittype_color'),
+        ('extras', '0118_notifications'),
+        ('tenancy', '0015_contactassignment_rename_content_type'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CircuitGroup',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                (
+                    'custom_field_data',
+                    models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+                ),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                (
+                    'tenant',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name='circuit_groups',
+                        to='tenancy.tenant',
+                    ),
+                ),
+            ],
+            options={
+                'verbose_name': 'Circuit group',
+                'verbose_name_plural': 'Circuit group',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.CreateModel(
+            name='CircuitGroupAssignment',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                (
+                    'custom_field_data',
+                    models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+                ),
+                ('priority', models.CharField(blank=True, max_length=50)),
+                (
+                    'circuit',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='assignments',
+                        to='circuits.circuit',
+                    ),
+                ),
+                (
+                    'group',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='assignments',
+                        to='circuits.circuitgroup',
+                    ),
+                ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'Circuit group assignment',
+                'verbose_name_plural': 'Circuit group assignments',
+                'ordering': ('circuit', 'priority', 'pk'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='circuitgroupassignment',
+            constraint=models.UniqueConstraint(
+                fields=('circuit', 'group'), name='circuits_circuitgroupassignment_unique_circuit_group'
+            ),
+        ),
+    ]

+ 72 - 1
netbox/circuits/models/circuits.py

@@ -6,11 +6,13 @@ from django.utils.translation import gettext_lazy as _
 from circuits.choices import *
 from dcim.models import CabledObjectModel
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
-from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
+from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, ImageAttachmentsMixin, TagsMixin
 from utilities.fields import ColorField
 
 __all__ = (
     'Circuit',
+    'CircuitGroup',
+    'CircuitGroupAssignment',
     'CircuitTermination',
     'CircuitType',
 )
@@ -151,6 +153,75 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
             raise ValidationError({'provider_account': "The assigned account must belong to the assigned provider."})
 
 
+class CircuitGroup(OrganizationalModel):
+    """
+    An administrative grouping of Circuits.
+    """
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='circuit_groups',
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('circuit group')
+        verbose_name_plural = _('circuit groups')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('circuits:circuitgroup', args=[self.pk])
+
+
+class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
+    """
+    Assignment of a Circuit to a CircuitGroup with an optional priority.
+    """
+    circuit = models.ForeignKey(
+        Circuit,
+        on_delete=models.CASCADE,
+        related_name='assignments'
+    )
+    group = models.ForeignKey(
+        CircuitGroup,
+        on_delete=models.CASCADE,
+        related_name='assignments'
+    )
+    priority = models.CharField(
+        verbose_name=_('priority'),
+        max_length=50,
+        choices=CircuitPriorityChoices,
+        blank=True
+    )
+    prerequisite_models = (
+        'circuits.Circuit',
+        'circuits.CircuitGroup',
+    )
+
+    class Meta:
+        ordering = ('circuit', 'priority', 'pk')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('circuit', 'group'),
+                name='%(app_label)s_%(class)s_unique_circuit_group'
+            ),
+        )
+        verbose_name = _('Circuit group assignment')
+        verbose_name_plural = _('Circuit group assignments')
+
+    def __str__(self):
+        if self.priority:
+            return f"{self.group} ({self.get_priority_display()})"
+        return str(self.group)
+
+    def get_absolute_url(self):
+        return reverse('circuits:circuitgroupassignment', args=[self.pk])
+
+
 class CircuitTermination(
     CustomFieldsMixin,
     CustomLinksMixin,

+ 11 - 0
netbox/circuits/search.py

@@ -13,6 +13,17 @@ class CircuitIndex(SearchIndex):
     display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
 
 
+@register_search
+class CircuitGroupIndex(SearchIndex):
+    model = models.CircuitGroup
+    fields = (
+        ('name', 100),
+        ('slug', 110),
+        ('description', 500),
+    )
+    display_attrs = ('description',)
+
+
 @register_search
 class CircuitTerminationIndex(SearchIndex):
     model = models.CircuitTermination

+ 49 - 0
netbox/circuits/tables/circuits.py

@@ -9,6 +9,8 @@ from netbox.tables import NetBoxTable, columns
 from .columns import CommitRateColumn
 
 __all__ = (
+    'CircuitGroupAssignmentTable',
+    'CircuitGroupTable',
     'CircuitTable',
     'CircuitTerminationTable',
     'CircuitTypeTable',
@@ -119,3 +121,50 @@ class CircuitTerminationTable(NetBoxTable):
             'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions',
         )
         default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')
+
+
+class CircuitGroupTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    circuit_group_assignment_count = columns.LinkedCountColumn(
+        viewname='circuits:circuitgroupassignment_list',
+        url_params={'group_id': 'pk'},
+        verbose_name=_('Circuits')
+    )
+    tags = columns.TagColumn(
+        url_name='circuits:circuitgroup_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = CircuitGroup
+        fields = (
+            'pk', 'name', 'description', 'circuit_group_assignment_count', 'tags',
+            'created', 'last_updated', 'actions',
+        )
+        default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')
+
+
+class CircuitGroupAssignmentTable(NetBoxTable):
+    group = tables.Column(
+        verbose_name=_('Group'),
+        linkify=True
+    )
+    circuit = tables.Column(
+        verbose_name=_('Circuit'),
+        linkify=True
+    )
+    priority = tables.Column(
+        verbose_name=_('Priority'),
+    )
+    tags = columns.TagColumn(
+        url_name='circuits:circuitgroupassignment_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = CircuitGroupAssignment
+        fields = (
+            'pk', 'id', 'group', 'circuit', 'priority', 'created', 'last_updated', 'actions', 'tags',
+        )
+        default_columns = ('pk', 'group', 'circuit', 'priority')

+ 103 - 0
netbox/circuits/tests/test_api.py

@@ -206,6 +206,38 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
         }
 
 
+class CircuitGroupTest(APIViewTestCases.APIViewTestCase):
+    model = CircuitGroup
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        circuit_groups = (
+            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.objects.bulk_create(circuit_groups)
+
+        cls.create_data = [
+            {
+                'name': 'Circuit Group 4',
+                'slug': 'circuit-group-4',
+            },
+            {
+                'name': 'Circuit Group 5',
+                'slug': 'circuit-group-5',
+            },
+            {
+                'name': 'Circuit Group 6',
+                'slug': 'circuit-group-6',
+            },
+        ]
+
+
 class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
     model = ProviderAccount
     brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
@@ -249,6 +281,77 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
         }
 
 
+class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
+    model = CircuitGroupAssignment
+    brief_fields = ['circuit', 'display', 'group', 'id', 'priority', 'url']
+    bulk_update_data = {
+        'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        circuit_groups = (
+            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(name='Circuit Group 5', slug='circuit-group-5'),
+            CircuitGroup(name='Circuit Group 6', slug='circuit-group-6'),
+        )
+        CircuitGroup.objects.bulk_create(circuit_groups)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+        circuits = (
+            Circuit(cid='Circuit 1', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 2', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 3', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 4', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 5', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 6', provider=provider, type=circuittype),
+        )
+        Circuit.objects.bulk_create(circuits)
+
+        assignments = (
+            CircuitGroupAssignment(
+                group=circuit_groups[0],
+                circuit=circuits[0],
+                priority=CircuitPriorityChoices.PRIORITY_PRIMARY
+            ),
+            CircuitGroupAssignment(
+                group=circuit_groups[1],
+                circuit=circuits[1],
+                priority=CircuitPriorityChoices.PRIORITY_SECONDARY
+            ),
+            CircuitGroupAssignment(
+                group=circuit_groups[2],
+                circuit=circuits[2],
+                priority=CircuitPriorityChoices.PRIORITY_TERTIARY
+            ),
+        )
+        CircuitGroupAssignment.objects.bulk_create(assignments)
+
+        cls.create_data = [
+            {
+                'group': circuit_groups[3].pk,
+                'circuit': circuits[3].pk,
+                'priority': CircuitPriorityChoices.PRIORITY_PRIMARY,
+            },
+            {
+                'group': circuit_groups[4].pk,
+                'circuit': circuits[4].pk,
+                'priority': CircuitPriorityChoices.PRIORITY_SECONDARY,
+            },
+            {
+                'group': circuit_groups[5].pk,
+                'circuit': circuits[5].pk,
+                'priority': CircuitPriorityChoices.PRIORITY_TERTIARY,
+            },
+        ]
+
+
 class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
     model = ProviderNetwork
     brief_fields = ['description', 'display', 'id', 'name', 'url']

+ 116 - 0
netbox/circuits/tests/test_filtersets.py

@@ -451,6 +451,122 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
 
 
+class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = CircuitGroup.objects.all()
+    filterset = CircuitGroupFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        tenant_groups = (
+            TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
+            TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
+        )
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        CircuitGroup.objects.bulk_create((
+            CircuitGroup(name='Circuit Group 1', slug='circuit-group-1', description='foobar1', tenant=tenants[0]),
+            CircuitGroup(name='Circuit Group 2', slug='circuit-group-2', description='foobar2', tenant=tenants[1]),
+            CircuitGroup(name='Circuit Group 3', slug='circuit-group-3', tenant=tenants[1]),
+        ))
+
+    def test_q(self):
+        params = {'q': 'foobar1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        params = {'name': ['Circuit Group 1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_slug(self):
+        params = {'slug': ['circuit-group-1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_tenant(self):
+        tenants = Tenant.objects.all()[:2]
+        params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'tenant': [tenants[0].slug, tenants[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_tenant_group(self):
+        tenant_groups = TenantGroup.objects.all()[:2]
+        params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+
+class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = CircuitGroupAssignment.objects.all()
+    filterset = CircuitGroupAssignmentFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        circuit_groups = (
+            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)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+        circuits = (
+            Circuit(cid='Circuit 1', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 2', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 3', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 4', provider=provider, type=circuittype),
+        )
+        Circuit.objects.bulk_create(circuits)
+
+        assignments = (
+            CircuitGroupAssignment(
+                group=circuit_groups[0],
+                circuit=circuits[0],
+                priority=CircuitPriorityChoices.PRIORITY_PRIMARY
+            ),
+            CircuitGroupAssignment(
+                group=circuit_groups[1],
+                circuit=circuits[1],
+                priority=CircuitPriorityChoices.PRIORITY_SECONDARY
+            ),
+            CircuitGroupAssignment(
+                group=circuit_groups[2],
+                circuit=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'])
+        params = {'group_id': [groups[0].pk, groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'group': [groups[0].slug, groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_circuit_id(self):
+        circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2'])
+        params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ProviderNetwork.objects.all()
     filterset = ProviderNetworkFilterSet

+ 106 - 0
netbox/circuits/tests/test_views.py

@@ -404,3 +404,109 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
         self.assertHttpStatus(response, 200)
+
+
+class CircuitGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = CircuitGroup
+
+    @classmethod
+    def setUpTestData(cls):
+
+        circuit_groups = (
+            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.objects.bulk_create(circuit_groups)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Circuit Group X',
+            'slug': 'circuit-group-x',
+            'description': 'A new Circuit Group',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,slug",
+            "Circuit Group 4,circuit-group-4",
+            "Circuit Group 5,circuit-group-5",
+            "Circuit Group 6,circuit-group-6",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{circuit_groups[0].pk},Circuit Group 7,New description7",
+            f"{circuit_groups[1].pk},Circuit Group 8,New description8",
+            f"{circuit_groups[2].pk},Circuit Group 9,New description9",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'Foo',
+        }
+
+
+class CircuitGroupAssignmentTestCase(
+    ViewTestCases.CreateObjectViewTestCase,
+    ViewTestCases.EditObjectViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkEditObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase
+):
+    model = CircuitGroupAssignment
+
+    @classmethod
+    def setUpTestData(cls):
+
+        circuit_groups = (
+            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)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+        circuits = (
+            Circuit(cid='Circuit 1', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 2', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 3', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 4', provider=provider, type=circuittype),
+        )
+        Circuit.objects.bulk_create(circuits)
+
+        assignments = (
+            CircuitGroupAssignment(
+                group=circuit_groups[0],
+                circuit=circuits[0],
+                priority=CircuitPriorityChoices.PRIORITY_PRIMARY
+            ),
+            CircuitGroupAssignment(
+                group=circuit_groups[1],
+                circuit=circuits[1],
+                priority=CircuitPriorityChoices.PRIORITY_SECONDARY
+            ),
+            CircuitGroupAssignment(
+                group=circuit_groups[2],
+                circuit=circuits[2],
+                priority=CircuitPriorityChoices.PRIORITY_TERTIARY
+            ),
+        )
+        CircuitGroupAssignment.objects.bulk_create(assignments)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'group': circuit_groups[3].pk,
+            'circuit': circuits[3].pk,
+            'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
+        }

+ 15 - 0
netbox/circuits/urls.py

@@ -55,4 +55,19 @@ urlpatterns = [
     path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'),
     path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
 
+    # Circuit Groups
+    path('circuit-groups/', views.CircuitGroupListView.as_view(), name='circuitgroup_list'),
+    path('circuit-groups/add/', views.CircuitGroupEditView.as_view(), name='circuitgroup_add'),
+    path('circuit-groups/import/', views.CircuitGroupBulkImportView.as_view(), name='circuitgroup_import'),
+    path('circuit-groups/edit/', views.CircuitGroupBulkEditView.as_view(), name='circuitgroup_bulk_edit'),
+    path('circuit-groups/delete/', views.CircuitGroupBulkDeleteView.as_view(), name='circuitgroup_bulk_delete'),
+    path('circuit-groups/<int:pk>/', include(get_model_urls('circuits', 'circuitgroup'))),
+
+    # Circuit Group Assignments
+    path('circuit-group-assignments/', views.CircuitGroupAssignmentListView.as_view(), name='circuitgroupassignment_list'),
+    path('circuit-group-assignments/add/', views.CircuitGroupAssignmentEditView.as_view(), name='circuitgroupassignment_add'),
+    path('circuit-group-assignments/import/', views.CircuitGroupAssignmentBulkImportView.as_view(), name='circuitgroupassignment_import'),
+    path('circuit-group-assignments/edit/', views.CircuitGroupAssignmentBulkEditView.as_view(), name='circuitgroupassignment_bulk_edit'),
+    path('circuit-group-assignments/delete/', views.CircuitGroupAssignmentBulkDeleteView.as_view(), name='circuitgroupassignment_bulk_delete'),
+    path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
 ]

+ 97 - 0
netbox/circuits/views.py

@@ -440,3 +440,100 @@ class CircuitTerminationBulkDeleteView(generic.BulkDeleteView):
 
 # Trace view
 register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)
+
+
+#
+# Circuit Groups
+#
+
+class CircuitGroupListView(generic.ObjectListView):
+    queryset = CircuitGroup.objects.annotate(
+        circuit_group_assignment_count=count_related(CircuitGroupAssignment, 'group')
+    )
+    filterset = filtersets.CircuitGroupFilterSet
+    filterset_form = forms.CircuitGroupFilterForm
+    table = tables.CircuitGroupTable
+
+
+@register_model_view(CircuitGroup)
+class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
+    queryset = CircuitGroup.objects.all()
+
+    def get_extra_context(self, request, instance):
+        return {
+            'related_models': self.get_related_models(request, instance),
+        }
+
+
+@register_model_view(CircuitGroup, 'edit')
+class CircuitGroupEditView(generic.ObjectEditView):
+    queryset = CircuitGroup.objects.all()
+    form = forms.CircuitGroupForm
+
+
+@register_model_view(CircuitGroup, 'delete')
+class CircuitGroupDeleteView(generic.ObjectDeleteView):
+    queryset = CircuitGroup.objects.all()
+
+
+class CircuitGroupBulkImportView(generic.BulkImportView):
+    queryset = CircuitGroup.objects.all()
+    model_form = forms.CircuitGroupImportForm
+
+
+class CircuitGroupBulkEditView(generic.BulkEditView):
+    queryset = CircuitGroup.objects.all()
+    filterset = filtersets.CircuitGroupFilterSet
+    table = tables.CircuitGroupTable
+    form = forms.CircuitGroupBulkEditForm
+
+
+class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
+    queryset = CircuitGroup.objects.all()
+    filterset = filtersets.CircuitGroupFilterSet
+    table = tables.CircuitGroupTable
+
+
+#
+# Circuit Groups
+#
+
+class CircuitGroupAssignmentListView(generic.ObjectListView):
+    queryset = CircuitGroupAssignment.objects.all()
+    filterset = filtersets.CircuitGroupAssignmentFilterSet
+    filterset_form = forms.CircuitGroupAssignmentFilterForm
+    table = tables.CircuitGroupAssignmentTable
+
+
+@register_model_view(CircuitGroupAssignment)
+class CircuitGroupAssignmentView(generic.ObjectView):
+    queryset = CircuitGroupAssignment.objects.all()
+
+
+@register_model_view(CircuitGroupAssignment, 'edit')
+class CircuitGroupAssignmentEditView(generic.ObjectEditView):
+    queryset = CircuitGroupAssignment.objects.all()
+    form = forms.CircuitGroupAssignmentForm
+
+
+@register_model_view(CircuitGroupAssignment, 'delete')
+class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
+    queryset = CircuitGroupAssignment.objects.all()
+
+
+class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
+    queryset = CircuitGroupAssignment.objects.all()
+    model_form = forms.CircuitGroupAssignmentImportForm
+
+
+class CircuitGroupAssignmentBulkEditView(generic.BulkEditView):
+    queryset = CircuitGroupAssignment.objects.all()
+    filterset = filtersets.CircuitGroupAssignmentFilterSet
+    table = tables.CircuitGroupAssignmentTable
+    form = forms.CircuitGroupAssignmentBulkEditForm
+
+
+class CircuitGroupAssignmentBulkDeleteView(generic.BulkDeleteView):
+    queryset = CircuitGroupAssignment.objects.all()
+    filterset = filtersets.CircuitGroupAssignmentFilterSet
+    table = tables.CircuitGroupAssignmentTable

+ 2 - 0
netbox/extras/tests/test_filtersets.py

@@ -1098,6 +1098,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'asnrange',
         'cable',
         'circuit',
+        'circuitgroup',
+        'circuitgroupassignment',
         'circuittermination',
         'circuittype',
         'cluster',

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

@@ -259,6 +259,8 @@ 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')),
             ),
         ),

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

@@ -61,6 +61,19 @@
           </tr>
         </table>
       </div>
+      <div class="card">
+        <h5 class="card-header">
+          {% 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">
+                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
+              </a>
+            </div>
+          {% endif %}
+        </h5>
+        {% htmx_table 'circuits:circuitgroupassignment_list' circuit_id=object.pk %}
+      </div>
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/tags.html' %}
       {% include 'inc/panels/comments.html' %}

+ 56 - 0
netbox/templates/circuits/circuitgroup.html

@@ -0,0 +1,56 @@
+{% extends 'generic/object.html' %}
+{% load static %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item"><a href="{% url 'circuits:circuitgroup_list' %}?circuitgroup_id={{ object.id }}">{{ object.name }}</a></li>
+{% endblock %}
+
+{% block extra_controls %}
+  {% if perms.circuit.add_circuitgroupassignment %}
+    <a href="{% url 'circuits:circuitgroupassignment_add' %}?group={{ object.pk }}" class="btn btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Circuit" %}
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Circuit Group" %}</h5>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Name" %}</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Tenant" %}</th>
+            <td>
+              {% if object.tenant.group %}
+                {{ object.tenant.group|linkify }} /
+              {% endif %}
+              {{ object.tenant|linkify|placeholder }}
+            </td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/related_objects.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 48 - 0
netbox/templates/circuits/circuitgroupassignment.html

@@ -0,0 +1,48 @@
+{% extends 'generic/object.html' %}
+{% load static %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:circuitgroupassignment_list' %}?group_id={{ object.group_id }}">{{ object.group }}</a>
+  </li>
+{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">{% trans "Circuit Group Assignment" %}</h5>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Group" %}</th>
+            <td>{{ object.group }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Circuit" %}</th>
+            <td>{{ object.circuit }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Priority" %}</th>
+            <td>{{ object.priority }}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}