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