From 6bc9302ce5ecd9ce008cab9a61f26e0d54e76c34 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Thu, 6 Mar 2025 16:06:06 -0600 Subject: [PATCH] Closes #17608: Adds L2VPN.status field (#18791) --- docs/models/vpn/l2vpn.md | 13 +++++ netbox/templates/vpn/l2vpn.html | 4 ++ netbox/vpn/api/serializers_/l2vpn.py | 2 +- netbox/vpn/choices.py | 14 +++++ netbox/vpn/filtersets.py | 5 +- netbox/vpn/forms/bulk_edit.py | 6 +- netbox/vpn/forms/bulk_import.py | 5 ++ netbox/vpn/forms/filtersets.py | 7 ++- netbox/vpn/forms/model_forms.py | 6 +- .../vpn/migrations/0008_add_l2vpn_status.py | 16 ++++++ netbox/vpn/models/l2vpn.py | 13 ++++- netbox/vpn/search.py | 2 +- netbox/vpn/tables/l2vpn.py | 9 ++- netbox/vpn/tests/test_api.py | 57 +++++++++++++++++-- netbox/vpn/tests/test_filtersets.py | 12 ++++ netbox/vpn/tests/test_views.py | 23 ++++++-- 16 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 netbox/vpn/migrations/0008_add_l2vpn_status.py diff --git a/docs/models/vpn/l2vpn.md b/docs/models/vpn/l2vpn.md index 1167c1c17..983095ef8 100644 --- a/docs/models/vpn/l2vpn.md +++ b/docs/models/vpn/l2vpn.md @@ -33,6 +33,19 @@ The technology employed in forming and operating the L2VPN. Choices include: !!! note Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. +### Status + +The operational status of the L2VPN. By default, the following statuses are available: + +* Active (default) +* Planned +* Faulty + +!!! tip "Custom L2VPN statuses" + Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +!!! info "This field was introduced in NetBox v4.3." + ### Identifier An optional numeric identifier. This can be used to track a pseudowire ID, for example. diff --git a/netbox/templates/vpn/l2vpn.html b/netbox/templates/vpn/l2vpn.html index 7f64d8086..2a826bc80 100644 --- a/netbox/templates/vpn/l2vpn.html +++ b/netbox/templates/vpn/l2vpn.html @@ -22,6 +22,10 @@ {% trans "Type" %} {{ object.get_type_display }} + + {% trans "Status" %} + {% badge object.get_status_display bg_color=object.get_status_color %} + {% trans "Description" %} {{ object.description|placeholder }} diff --git a/netbox/vpn/api/serializers_/l2vpn.py b/netbox/vpn/api/serializers_/l2vpn.py index c16cbbe1d..2148a81c8 100644 --- a/netbox/vpn/api/serializers_/l2vpn.py +++ b/netbox/vpn/api/serializers_/l2vpn.py @@ -38,7 +38,7 @@ class L2VPNSerializer(NetBoxModelSerializer): class Meta: model = L2VPN fields = [ - 'id', 'url', 'display_url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', + 'id', 'url', 'display_url', 'display', 'identifier', 'name', 'slug', 'type', 'status', 'import_targets', 'export_targets', 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description') diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 7aea90232..db03e48f8 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -267,3 +267,17 @@ class L2VPNTypeChoices(ChoiceSet): TYPE_EPLAN, TYPE_EPTREE ) + + +class L2VPNStatusChoices(ChoiceSet): + key = 'L2VPN.status' + + STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' + STATUS_DECOMMISSIONING = 'decommissioning' + + CHOICES = [ + (STATUS_ACTIVE, _('Active'), 'green'), + (STATUS_PLANNED, _('Planned'), 'cyan'), + (STATUS_DECOMMISSIONING, _('Decommissioning'), 'red'), + ] diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 6403b662f..d7d06f991 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -298,6 +298,9 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): choices=L2VPNTypeChoices, null_value=None ) + status = django_filters.MultipleChoiceFilter( + choices=L2VPNStatusChoices, + ) import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), @@ -323,7 +326,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = L2VPN - fields = ('id', 'identifier', 'name', 'slug', 'type', 'description') + fields = ('id', 'identifier', 'name', 'slug', 'status', 'type', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index a7595a2a7..700dadb70 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -260,6 +260,10 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + status = forms.ChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + ) type = forms.ChoiceField( label=_('Type'), choices=add_blank_choice(L2VPNTypeChoices), @@ -279,7 +283,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): model = L2VPN fieldsets = ( - FieldSet('type', 'tenant', 'description'), + FieldSet('status', 'type', 'tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index b8d19bb38..925558e60 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -260,6 +260,11 @@ class L2VPNImportForm(NetBoxModelImportForm): required=False, to_field_name='name', ) + status = CSVChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + help_text=_('Operational status') + ) type = CSVChoiceField( label=_('Type'), choices=L2VPNTypeChoices, diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 10dc441e2..5503166f0 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -210,9 +210,14 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('type', 'import_target_id', 'export_target_id', name=_('Attributes')), + FieldSet('type', 'status', 'import_target_id', 'export_target_id', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + required=False + ) type = forms.ChoiceField( label=_('Type'), choices=add_blank_choice(L2VPNTypeChoices), diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index d6d02b4f5..1bf5b580c 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -409,7 +409,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - FieldSet('name', 'slug', 'type', 'identifier', 'description', 'tags', name=_('L2VPN')), + FieldSet('name', 'slug', 'type', 'status', 'identifier', 'description', 'tags', name=_('L2VPN')), FieldSet('import_targets', 'export_targets', name=_('Route Targets')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -417,8 +417,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): class Meta: model = L2VPN fields = ( - 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', - 'comments', 'tags' + 'name', 'slug', 'type', 'status', 'identifier', 'import_targets', 'export_targets', 'tenant', + 'description', 'comments', 'tags' ) diff --git a/netbox/vpn/migrations/0008_add_l2vpn_status.py b/netbox/vpn/migrations/0008_add_l2vpn_status.py new file mode 100644 index 000000000..8b0267e45 --- /dev/null +++ b/netbox/vpn/migrations/0008_add_l2vpn_status.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0007_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='l2vpn', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py index 3e562531d..575f6e234 100644 --- a/netbox/vpn/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from core.models import ObjectType from netbox.models import NetBoxModel, PrimaryModel from netbox.models.features import ContactsMixin -from vpn.choices import L2VPNTypeChoices +from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices from vpn.constants import L2VPN_ASSIGNMENT_MODELS __all__ = ( @@ -33,6 +33,12 @@ class L2VPN(ContactsMixin, PrimaryModel): max_length=50, choices=L2VPNTypeChoices ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=L2VPNStatusChoices, + default=L2VPNStatusChoices.STATUS_ACTIVE, + ) identifier = models.BigIntegerField( verbose_name=_('identifier'), null=True, @@ -56,7 +62,7 @@ class L2VPN(ContactsMixin, PrimaryModel): null=True ) - clone_fields = ('type',) + clone_fields = ('type', 'status') class Meta: ordering = ('name', 'identifier') @@ -68,6 +74,9 @@ class L2VPN(ContactsMixin, PrimaryModel): return f'{self.name} ({self.identifier})' return f'{self.name}' + def get_status_color(self): + return L2VPNStatusChoices.colors.get(self.status) + @cached_property def can_add_termination(self): if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2: diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py index c1914dc22..07ab9a5ca 100644 --- a/netbox/vpn/search.py +++ b/netbox/vpn/search.py @@ -79,4 +79,4 @@ class L2VPNIndex(SearchIndex): ('description', 500), ('comments', 5000), ) - display_attrs = ('type', 'identifier', 'tenant', 'description') + display_attrs = ('type', 'status', 'identifier', 'tenant', 'description') diff --git a/netbox/vpn/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py index 9a614ab98..95586461e 100644 --- a/netbox/vpn/tables/l2vpn.py +++ b/netbox/vpn/tables/l2vpn.py @@ -23,6 +23,9 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status') + ) import_targets = columns.TemplateColumn( verbose_name=_('Import Targets'), template_code=L2VPN_TARGETS, @@ -43,10 +46,10 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = L2VPN fields = ( - 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', - 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'name', 'slug', 'status', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', + 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'identifier', 'type', 'description') + default_columns = ('pk', 'name', 'status', 'identifier', 'type', 'description') class L2VPNTerminationTable(NetBoxTable): diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index f2d43718f..19fdf1136 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -1,4 +1,5 @@ from django.urls import reverse +from rest_framework import status from dcim.choices import InterfaceTypeChoices from dcim.models import Interface @@ -527,19 +528,22 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): 'name': 'L2VPN 4', 'slug': 'l2vpn-4', 'type': 'vxlan', - 'identifier': 33343344 + 'identifier': 33343344, + 'status': L2VPNStatusChoices.STATUS_ACTIVE, }, { 'name': 'L2VPN 5', 'slug': 'l2vpn-5', 'type': 'vxlan', - 'identifier': 33343345 + 'identifier': 33343345, + 'status': L2VPNStatusChoices.STATUS_PLANNED, }, { 'name': 'L2VPN 6', 'slug': 'l2vpn-6', 'type': 'vpws', - 'identifier': 33343346 + 'identifier': 33343346, + 'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING, }, ] bulk_update_data = { @@ -550,12 +554,53 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD + L2VPN( + name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001, + status=L2VPNStatusChoices.STATUS_ACTIVE, + ), + L2VPN( + name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002, + status=L2VPNStatusChoices.STATUS_PLANNED, + ), + L2VPN( + name='L2VPN 3', slug='l2vpn-3', type='vpls', + status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, + ), # No RD ) L2VPN.objects.bulk_create(l2vpns) + def test_status_filter(self): + url = reverse('vpn-api:l2vpn-list') + + self.add_permissions('vpn.view_l2vpn') + response = self.client.get(url, **self.header) + response_data = response.json() + + # all L2VPNs present with not filter + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 3) + + # 1 L2VPN present with active status filter + filter_url = f'{url}?status={L2VPNStatusChoices.STATUS_ACTIVE}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 1) + + # 2 L2VPNs present with active and planned status filter + filter_url = f'{filter_url}&status={L2VPNStatusChoices.STATUS_PLANNED}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 2) + + # 1 L2VPN present with decommissioning status filter + filter_url = f'{url}?status={L2VPNStatusChoices.STATUS_DECOMMISSIONING}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 1) + class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): model = L2VPNTermination diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index d2b893766..ee1f9ca72 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -769,6 +769,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, + status=L2VPNStatusChoices.STATUS_ACTIVE, identifier=65001, description='foobar1' ), @@ -776,6 +777,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, + status=L2VPNStatusChoices.STATUS_PLANNED, identifier=65002, description='foobar2' ), @@ -783,6 +785,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS, + status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, description='foobar3' ), ) @@ -814,6 +817,15 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + self.assertEqual(self.filterset({}, self.queryset).qs.count(), 3) + + params = {'status': [L2VPNStatusChoices.STATUS_ACTIVE, L2VPNStatusChoices.STATUS_PLANNED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'status': [L2VPNStatusChoices.STATUS_DECOMMISSIONING]} + 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) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py index 05ac527fe..6d2239169 100644 --- a/netbox/vpn/tests/test_views.py +++ b/netbox/vpn/tests/test_views.py @@ -574,16 +574,25 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): RouteTarget.objects.bulk_create(rts) l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') + L2VPN( + name='L2VPN 1', slug='l2vpn-1', status=L2VPNStatusChoices.STATUS_ACTIVE, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001' + ), + L2VPN( + name='L2VPN 2', slug='l2vpn-2', status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002' + ), + L2VPN( + name='L2VPN 3', slug='l2vpn-3', status=L2VPNStatusChoices.STATUS_PLANNED, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003' + ) ) L2VPN.objects.bulk_create(l2vpns) cls.csv_data = ( - 'name,slug,type,identifier', - 'L2VPN 5,l2vpn-5,vxlan,456', - 'L2VPN 6,l2vpn-6,vxlan,444', + 'name,status,slug,type,identifier', + 'L2VPN 5,active,l2vpn-5,vxlan,456', + 'L2VPN 6,planned,l2vpn-6,vxlan,444', ) cls.csv_update_data = ( @@ -594,12 +603,14 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'description': 'New Description', + 'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING, } cls.form_data = { 'name': 'L2VPN 8', 'slug': 'l2vpn-8', 'type': L2VPNTypeChoices.TYPE_VXLAN, + 'status': L2VPNStatusChoices.STATUS_PLANNED, 'identifier': 123, 'description': 'Description', 'import_targets': [rts[0].pk],