Closes #17608: Adds L2VPN.status field (#18791)

This commit is contained in:
Jason Novinger 2025-03-06 16:06:06 -06:00 committed by GitHub
parent 4e65117e7c
commit 6bc9302ce5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 169 additions and 25 deletions

View File

@ -33,6 +33,19 @@ The technology employed in forming and operating the L2VPN. Choices include:
!!! note !!! note
Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. 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 ### Identifier
An optional numeric identifier. This can be used to track a pseudowire ID, for example. An optional numeric identifier. This can be used to track a pseudowire ID, for example.

View File

@ -22,6 +22,10 @@
<th scope="row">{% trans "Type" %}</th> <th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display }}</td> <td>{{ object.get_type_display }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>

View File

@ -38,7 +38,7 @@ class L2VPNSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = L2VPN model = L2VPN
fields = [ 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' 'export_targets', 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
] ]
brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description') brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description')

View File

@ -267,3 +267,17 @@ class L2VPNTypeChoices(ChoiceSet):
TYPE_EPLAN, TYPE_EPLAN,
TYPE_EPTREE 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'),
]

View File

@ -298,6 +298,9 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
choices=L2VPNTypeChoices, choices=L2VPNTypeChoices,
null_value=None null_value=None
) )
status = django_filters.MultipleChoiceFilter(
choices=L2VPNStatusChoices,
)
import_target_id = django_filters.ModelMultipleChoiceFilter( import_target_id = django_filters.ModelMultipleChoiceFilter(
field_name='import_targets', field_name='import_targets',
queryset=RouteTarget.objects.all(), queryset=RouteTarget.objects.all(),
@ -323,7 +326,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = L2VPN model = L2VPN
fields = ('id', 'identifier', 'name', 'slug', 'type', 'description') fields = ('id', 'identifier', 'name', 'slug', 'status', 'type', 'description')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -260,6 +260,10 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm):
class L2VPNBulkEditForm(NetBoxModelBulkEditForm): class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
label=_('Status'),
choices=L2VPNStatusChoices,
)
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'), label=_('Type'),
choices=add_blank_choice(L2VPNTypeChoices), choices=add_blank_choice(L2VPNTypeChoices),
@ -279,7 +283,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
model = L2VPN model = L2VPN
fieldsets = ( fieldsets = (
FieldSet('type', 'tenant', 'description'), FieldSet('status', 'type', 'tenant', 'description'),
) )
nullable_fields = ('tenant', 'description', 'comments') nullable_fields = ('tenant', 'description', 'comments')

View File

@ -260,6 +260,11 @@ class L2VPNImportForm(NetBoxModelImportForm):
required=False, required=False,
to_field_name='name', to_field_name='name',
) )
status = CSVChoiceField(
label=_('Status'),
choices=L2VPNStatusChoices,
help_text=_('Operational status')
)
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'), label=_('Type'),
choices=L2VPNTypeChoices, choices=L2VPNTypeChoices,

View File

@ -210,9 +210,14 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = L2VPN model = L2VPN
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), 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')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
) )
status = forms.MultipleChoiceField(
label=_('Status'),
choices=L2VPNStatusChoices,
required=False
)
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'), label=_('Type'),
choices=add_blank_choice(L2VPNTypeChoices), choices=add_blank_choice(L2VPNTypeChoices),

View File

@ -409,7 +409,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( 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('import_targets', 'export_targets', name=_('Route Targets')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
) )
@ -417,8 +417,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = L2VPN model = L2VPN
fields = ( fields = (
'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', 'name', 'slug', 'type', 'status', 'identifier', 'import_targets', 'export_targets', 'tenant',
'comments', 'tags' 'description', 'comments', 'tags'
) )

View File

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

View File

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType from core.models import ObjectType
from netbox.models import NetBoxModel, PrimaryModel from netbox.models import NetBoxModel, PrimaryModel
from netbox.models.features import ContactsMixin from netbox.models.features import ContactsMixin
from vpn.choices import L2VPNTypeChoices from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices
from vpn.constants import L2VPN_ASSIGNMENT_MODELS from vpn.constants import L2VPN_ASSIGNMENT_MODELS
__all__ = ( __all__ = (
@ -33,6 +33,12 @@ class L2VPN(ContactsMixin, PrimaryModel):
max_length=50, max_length=50,
choices=L2VPNTypeChoices choices=L2VPNTypeChoices
) )
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=L2VPNStatusChoices,
default=L2VPNStatusChoices.STATUS_ACTIVE,
)
identifier = models.BigIntegerField( identifier = models.BigIntegerField(
verbose_name=_('identifier'), verbose_name=_('identifier'),
null=True, null=True,
@ -56,7 +62,7 @@ class L2VPN(ContactsMixin, PrimaryModel):
null=True null=True
) )
clone_fields = ('type',) clone_fields = ('type', 'status')
class Meta: class Meta:
ordering = ('name', 'identifier') ordering = ('name', 'identifier')
@ -68,6 +74,9 @@ class L2VPN(ContactsMixin, PrimaryModel):
return f'{self.name} ({self.identifier})' return f'{self.name} ({self.identifier})'
return f'{self.name}' return f'{self.name}'
def get_status_color(self):
return L2VPNStatusChoices.colors.get(self.status)
@cached_property @cached_property
def can_add_termination(self): def can_add_termination(self):
if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2: if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2:

View File

@ -79,4 +79,4 @@ class L2VPNIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('type', 'identifier', 'tenant', 'description') display_attrs = ('type', 'status', 'identifier', 'tenant', 'description')

View File

@ -23,6 +23,9 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
status = columns.ChoiceFieldColumn(
verbose_name=_('Status')
)
import_targets = columns.TemplateColumn( import_targets = columns.TemplateColumn(
verbose_name=_('Import Targets'), verbose_name=_('Import Targets'),
template_code=L2VPN_TARGETS, template_code=L2VPN_TARGETS,
@ -43,10 +46,10 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = L2VPN model = L2VPN
fields = ( fields = (
'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', 'pk', 'name', 'slug', 'status', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant',
'description', 'comments', 'tags', 'created', 'last_updated', '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): class L2VPNTerminationTable(NetBoxTable):

View File

@ -1,4 +1,5 @@
from django.urls import reverse from django.urls import reverse
from rest_framework import status
from dcim.choices import InterfaceTypeChoices from dcim.choices import InterfaceTypeChoices
from dcim.models import Interface from dcim.models import Interface
@ -527,19 +528,22 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase):
'name': 'L2VPN 4', 'name': 'L2VPN 4',
'slug': 'l2vpn-4', 'slug': 'l2vpn-4',
'type': 'vxlan', 'type': 'vxlan',
'identifier': 33343344 'identifier': 33343344,
'status': L2VPNStatusChoices.STATUS_ACTIVE,
}, },
{ {
'name': 'L2VPN 5', 'name': 'L2VPN 5',
'slug': 'l2vpn-5', 'slug': 'l2vpn-5',
'type': 'vxlan', 'type': 'vxlan',
'identifier': 33343345 'identifier': 33343345,
'status': L2VPNStatusChoices.STATUS_PLANNED,
}, },
{ {
'name': 'L2VPN 6', 'name': 'L2VPN 6',
'slug': 'l2vpn-6', 'slug': 'l2vpn-6',
'type': 'vpws', 'type': 'vpws',
'identifier': 33343346 'identifier': 33343346,
'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING,
}, },
] ]
bulk_update_data = { bulk_update_data = {
@ -550,12 +554,53 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
l2vpns = ( l2vpns = (
L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), L2VPN(
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001,
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD 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) 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): class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
model = L2VPNTermination model = L2VPNTermination

View File

@ -769,6 +769,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
name='L2VPN 1', name='L2VPN 1',
slug='l2vpn-1', slug='l2vpn-1',
type=L2VPNTypeChoices.TYPE_VXLAN, type=L2VPNTypeChoices.TYPE_VXLAN,
status=L2VPNStatusChoices.STATUS_ACTIVE,
identifier=65001, identifier=65001,
description='foobar1' description='foobar1'
), ),
@ -776,6 +777,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
name='L2VPN 2', name='L2VPN 2',
slug='l2vpn-2', slug='l2vpn-2',
type=L2VPNTypeChoices.TYPE_VPWS, type=L2VPNTypeChoices.TYPE_VPWS,
status=L2VPNStatusChoices.STATUS_PLANNED,
identifier=65002, identifier=65002,
description='foobar2' description='foobar2'
), ),
@ -783,6 +785,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
name='L2VPN 3', name='L2VPN 3',
slug='l2vpn-3', slug='l2vpn-3',
type=L2VPNTypeChoices.TYPE_VPLS, type=L2VPNTypeChoices.TYPE_VPLS,
status=L2VPNStatusChoices.STATUS_DECOMMISSIONING,
description='foobar3' description='foobar3'
), ),
) )
@ -814,6 +817,15 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_description(self):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -574,16 +574,25 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
RouteTarget.objects.bulk_create(rts) RouteTarget.objects.bulk_create(rts)
l2vpns = ( l2vpns = (
L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), L2VPN(
L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), name='L2VPN 1', slug='l2vpn-1', status=L2VPNStatusChoices.STATUS_ACTIVE,
L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') 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) L2VPN.objects.bulk_create(l2vpns)
cls.csv_data = ( cls.csv_data = (
'name,slug,type,identifier', 'name,status,slug,type,identifier',
'L2VPN 5,l2vpn-5,vxlan,456', 'L2VPN 5,active,l2vpn-5,vxlan,456',
'L2VPN 6,l2vpn-6,vxlan,444', 'L2VPN 6,planned,l2vpn-6,vxlan,444',
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -594,12 +603,14 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.bulk_edit_data = { cls.bulk_edit_data = {
'description': 'New Description', 'description': 'New Description',
'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING,
} }
cls.form_data = { cls.form_data = {
'name': 'L2VPN 8', 'name': 'L2VPN 8',
'slug': 'l2vpn-8', 'slug': 'l2vpn-8',
'type': L2VPNTypeChoices.TYPE_VXLAN, 'type': L2VPNTypeChoices.TYPE_VXLAN,
'status': L2VPNStatusChoices.STATUS_PLANNED,
'identifier': 123, 'identifier': 123,
'description': 'Description', 'description': 'Description',
'import_targets': [rts[0].pk], 'import_targets': [rts[0].pk],