diff --git a/docs/models/circuits/circuitgroup.md b/docs/models/circuits/circuitgroup.md new file mode 100644 index 00000000000..6d1503509be --- /dev/null +++ b/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.) diff --git a/docs/models/circuits/circuitgroupassignment.md b/docs/models/circuits/circuitgroupassignment.md new file mode 100644 index 00000000000..2aaa375af01 --- /dev/null +++ b/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. diff --git a/mkdocs.yml b/mkdocs.yml index 2efd268c8c9..841a9df47c9 100644 --- a/mkdocs.yml +++ b/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' diff --git a/netbox/circuits/api/serializers_/circuits.py b/netbox/circuits/api/serializers_/circuits.py index 7010bb2c6bb..9eed14c1150 100644 --- a/netbox/circuits/api/serializers_/circuits.py +++ b/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 @@ __all__ = ( 'CircuitSerializer', + 'CircuitGroupAssignmentSerializer', + 'CircuitGroupSerializer', 'CircuitTerminationSerializer', 'CircuitTypeSerializer', ) @@ -43,6 +45,34 @@ class Meta: ] +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 Meta: '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') diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index fcb7a1a512c..00af3dec684 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -14,6 +14,8 @@ 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 diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index fffb59a5740..8cce013d74f 100644 --- a/netbox/circuits/api/views.py +++ b/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 # diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index e2d345581a4..8c25c7459de 100644 --- a/netbox/circuits/choices.py +++ b/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')), + ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index e526738743d..509628a9da6 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -13,6 +13,8 @@ __all__ = ( 'CircuitFilterSet', + 'CircuitGroupAssignmentFilterSet', + 'CircuitGroupFilterSet', 'CircuitTerminationFilterSet', 'CircuitTypeFilterSet', 'ProviderNetworkFilterSet', @@ -303,3 +305,43 @@ def search(self, queryset, name, value): 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) + ) diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index ea15c30100c..3bb50a8d01a 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/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 @@ __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',) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 88fdd2c712d..1e7b6361a6e 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -11,6 +11,8 @@ __all__ = ( 'CircuitImportForm', + 'CircuitGroupAssignmentImportForm', + 'CircuitGroupImportForm', 'CircuitTerminationImportForm', 'CircuitTerminationImportRelatedForm', 'CircuitTypeImportForm', @@ -150,3 +152,24 @@ class Meta: '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') diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 6f6473c3d9e..b60ac97bcd0 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/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 @@ __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) diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index ee5e47ce713..554f2af5a63 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -12,6 +12,8 @@ __all__ = ( 'CircuitForm', + 'CircuitGroupAssignmentForm', + 'CircuitGroupForm', 'CircuitTerminationForm', 'CircuitTypeForm', 'ProviderForm', @@ -171,3 +173,35 @@ class Meta: 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', + ] diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 10887ce3f0c..3ded6e6812a 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -7,6 +7,8 @@ __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): diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index ac8626cc5da..58a9879afa1 100644 --- a/netbox/circuits/graphql/schema.py +++ b/netbox/circuits/graphql/schema.py @@ -24,6 +24,16 @@ def circuit_type(self, id: int) -> CircuitTypeType: 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) diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index bae91e6b02b..45f0d065d5f 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -6,13 +6,15 @@ 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')] diff --git a/netbox/circuits/migrations/0044_circuitgroup_circuitgroupassignment_and_more.py b/netbox/circuits/migrations/0044_circuitgroup_circuitgroupassignment_and_more.py new file mode 100644 index 00000000000..40ea5bd1e79 --- /dev/null +++ b/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' + ), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index fa21d7cd33d..7c5e5f2b5b7 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -6,11 +6,13 @@ 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 @@ def clean(self): 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, diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index f3fa359bae2..7a5711f0326 100644 --- a/netbox/circuits/search.py +++ b/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 diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index e1b99ff4257..3145df43eda 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -9,6 +9,8 @@ from .columns import CommitRateColumn __all__ = ( + 'CircuitGroupAssignmentTable', + 'CircuitGroupTable', 'CircuitTable', 'CircuitTerminationTable', 'CircuitTypeTable', @@ -119,3 +121,50 @@ class Meta(NetBoxTable.Meta): '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') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index d3745f2b155..a3c5cada9aa 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -206,6 +206,38 @@ def setUpTestData(cls): } +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 @@ def setUpTestData(cls): } +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'] diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index df10c3929e3..3d0f3f5aa9c 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -451,6 +451,122 @@ def test_occupied(self): 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 diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 577548703b4..87e6d99b724 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -404,3 +404,109 @@ def test_trace(self): 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, + } diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 5c0ab99ee7a..2171d49bea4 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -55,4 +55,19 @@ path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'), path('circuit-terminations//', 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//', 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//', include(get_model_urls('circuits', 'circuitgroupassignment'))), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b10b83b23dc..22ae7f1d298 100644 --- a/netbox/circuits/views.py +++ b/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 diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 77d4ed004b7..144dec5d0d5 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1098,6 +1098,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'asnrange', 'cable', 'circuit', + 'circuitgroup', + 'circuitgroupassignment', 'circuittermination', 'circuittype', 'cluster', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index b96465c3573..44f212f9c73 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -259,6 +259,8 @@ 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')), ), ), diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index fb3d8185ac4..3ad7ed4e649 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -61,6 +61,19 @@
{% trans "Circuit" %}
+
+
+ {% trans "Group Assignments" %} + {% if perms.circuits.add_circuitgroupassignment %} + + {% endif %} +
+ {% htmx_table 'circuits:circuitgroupassignment_list' circuit_id=object.pk %} +
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} diff --git a/netbox/templates/circuits/circuitgroup.html b/netbox/templates/circuits/circuitgroup.html new file mode 100644 index 00000000000..24002f59b37 --- /dev/null +++ b/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 }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.circuit.add_circuitgroupassignment %} + + {% trans "Assign Circuit" %} + + {% endif %} +{% endblock extra_controls %} + +{% block content %} +
+
+
+
{% trans "Circuit Group" %}
+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/related_objects.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/circuits/circuitgroupassignment.html b/netbox/templates/circuits/circuitgroupassignment.html new file mode 100644 index 00000000000..870e46be8c5 --- /dev/null +++ b/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 }} + +{% endblock %} + +{% block content %} +
+
+
+
{% trans "Circuit Group Assignment" %}
+ + + + + + + + + + + + + +
{% trans "Group" %}{{ object.group }}
{% trans "Circuit" %}{{ object.circuit }}
{% trans "Priority" %}{{ object.priority }}
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %}