Просмотр исходного кода

Address further review feedback on L2VPN IP address terminations

Brian Tiemann 16 часов назад
Родитель
Сommit
0b8bdbf94c

+ 4 - 1
netbox/ipam/graphql/filters.py

@@ -27,7 +27,7 @@ if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
     from core.graphql.filters import ContentTypeFilter
     from dcim.graphql.filters import SiteFilter
     from dcim.graphql.filters import SiteFilter
     from netbox.graphql.filter_lookups import BigIntegerLookup, IntegerLookup, IntegerRangeArrayLookup
     from netbox.graphql.filter_lookups import BigIntegerLookup, IntegerLookup, IntegerRangeArrayLookup
-    from vpn.graphql.filters import L2VPNFilter
+    from vpn.graphql.filters import L2VPNFilter, L2VPNTerminationFilter
 
 
     from .enums import *
     from .enums import *
 
 
@@ -198,6 +198,9 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
     )
     )
     nat_outside_id: ID | None = strawberry_django.filter_field()
     nat_outside_id: ID | None = strawberry_django.filter_field()
     dns_name: StrFilterLookup | None = strawberry_django.filter_field()
     dns_name: StrFilterLookup | None = strawberry_django.filter_field()
+    l2vpn_terminations: Annotated['L2VPNTerminationFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
 
 
     @strawberry_django.filter_field()
     @strawberry_django.filter_field()
     def assigned(self, value: bool, prefix) -> Q:
     def assigned(self, value: bool, prefix) -> Q:

+ 5 - 0
netbox/vpn/constants.py

@@ -6,3 +6,8 @@ L2VPN_ASSIGNMENT_MODELS = Q(
     Q(app_label='ipam', model='vlan') |
     Q(app_label='ipam', model='vlan') |
     Q(app_label='virtualization', model='vminterface')
     Q(app_label='virtualization', model='vminterface')
 )
 )
+
+# Models which may terminate more than one L2VPN (e.g. an IP address which is the far-end PE of several pseudowires)
+L2VPN_MULTI_TERMINATION_MODELS = (
+    'ipam.ipaddress',
+)

+ 10 - 4
netbox/vpn/filtersets.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
 from core.models import ObjectType
 from core.models import ObjectType
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
 from ipam.models import VLAN, IPAddress, RouteTarget
 from ipam.models import VLAN, IPAddress, RouteTarget
+from ipam.utils import parse_inet_addresses
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
@@ -484,10 +485,8 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         label=_('VLAN (ID)'),
         label=_('VLAN (ID)'),
     )
     )
-    ipaddress = django_filters.ModelMultipleChoiceFilter(
-        field_name='ipaddress__address',
-        queryset=IPAddress.objects.all(),
-        to_field_name='address',
+    ipaddress = MultiValueCharFilter(
+        method='filter_ipaddress',
         label=_('IP address'),
         label=_('IP address'),
     )
     )
     ipaddress_id = django_filters.ModelMultipleChoiceFilter(
     ipaddress_id = django_filters.ModelMultipleChoiceFilter(
@@ -530,6 +529,13 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
         )
         )
         return qs
         return qs
 
 
+    def filter_ipaddress(self, queryset, name, value):
+        # Match an address given without a mask regardless of the IP address's mask. Discard any invalid addresses.
+        value = parse_inet_addresses(value)
+        if not value:
+            return queryset.none()
+        return queryset.filter(ipaddress__address__net_in=value)
+
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
         if not value:
         if not value:
             return queryset
             return queryset

+ 27 - 5
netbox/vpn/forms/bulk_import.py

@@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
-from ipam.models import VLAN, IPAddress
+from ipam.models import VLAN, VRF, IPAddress
 from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, PrimaryModelImportForm
 from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, PrimaryModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
 from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
@@ -289,14 +289,14 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Parent device (for interface)')
+        help_text=_('Parent device (for interface or IP address)')
     )
     )
     virtual_machine = CSVModelChoiceField(
     virtual_machine = CSVModelChoiceField(
         label=_('Virtual machine'),
         label=_('Virtual machine'),
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Parent virtual machine (for interface)')
+        help_text=_('Parent virtual machine (for interface or IP address)')
     )
     )
     interface = CSVModelChoiceField(
     interface = CSVModelChoiceField(
         label=_('Interface'),
         label=_('Interface'),
@@ -319,25 +319,47 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
         to_field_name='address',
         to_field_name='address',
         help_text=_('Assigned IP address, with mask (e.g. the far-end PE of a pseudowire)')
         help_text=_('Assigned IP address, with mask (e.g. the far-end PE of a pseudowire)')
     )
     )
+    ipaddress_vrf = CSVModelChoiceField(
+        label=_('IP address VRF'),
+        queryset=VRF.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('VRF of the assigned IP address')
+    )
 
 
     class Meta:
     class Meta:
         model = L2VPNTermination
         model = L2VPNTermination
-        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'ipaddress', 'tags')
+        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'ipaddress', 'ipaddress_vrf', 'tags')
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)
 
 
         if data:
         if data:
 
 
-            # Limit interface queryset by device or VM
+            # Limit interface and IP address querysets by device or VM
             if data.get('device'):
             if data.get('device'):
                 self.fields['interface'].queryset = Interface.objects.filter(
                 self.fields['interface'].queryset = Interface.objects.filter(
                     **{f"device__{self.fields['device'].to_field_name}": data['device']}
                     **{f"device__{self.fields['device'].to_field_name}": data['device']}
                 )
                 )
+                self.fields['ipaddress'].queryset = IPAddress.objects.filter(
+                    **{f"interface__device__{self.fields['device'].to_field_name}": data['device']}
+                )
             elif data.get('virtual_machine'):
             elif data.get('virtual_machine'):
                 self.fields['interface'].queryset = VMInterface.objects.filter(
                 self.fields['interface'].queryset = VMInterface.objects.filter(
                     **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
                     **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
                 )
                 )
+                self.fields['ipaddress'].queryset = IPAddress.objects.filter(
+                    **{
+                        f"vminterface__virtual_machine__{self.fields['virtual_machine'].to_field_name}":
+                            data['virtual_machine']
+                    }
+                )
+
+            # Limit IP address queryset by VRF
+            if data.get('ipaddress_vrf'):
+                self.fields['ipaddress'].queryset = self.fields['ipaddress'].queryset.filter(
+                    **{f"vrf__{self.fields['ipaddress_vrf'].to_field_name}": data['ipaddress_vrf']}
+                )
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()

+ 8 - 13
netbox/vpn/models/l2vpn.py

@@ -9,6 +9,7 @@ from ipam.models import VLAN, IPAddress
 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 L2VPNStatusChoices, L2VPNTypeChoices
 from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices
+from vpn.constants import L2VPN_MULTI_TERMINATION_MODELS
 
 
 __all__ = (
 __all__ = (
     'L2VPN',
     'L2VPN',
@@ -130,9 +131,8 @@ class L2VPNTermination(NetBoxModel):
             terminations = L2VPNTermination.objects.filter(
             terminations = L2VPNTermination.objects.filter(
                 assigned_object_id=obj_id, assigned_object_type=obj_type
                 assigned_object_id=obj_id, assigned_object_type=obj_type
             ).exclude(pk=self.pk)
             ).exclude(pk=self.pk)
-            # An IP address (e.g. a pseudowire's far-end PE) may terminate many L2VPNs, but each only once. Other
-            # objects may terminate only one L2VPN.
-            if isinstance(self.assigned_object, IPAddress):
+            # Some objects may terminate many L2VPNs, but each only once. Others may terminate only one L2VPN.
+            if self.assigned_object._meta.label_lower in L2VPN_MULTI_TERMINATION_MODELS:
                 if terminations.filter(l2vpn_id=self.l2vpn_id).exists():
                 if terminations.filter(l2vpn_id=self.l2vpn_id).exists():
                     raise ValidationError(
                     raise ValidationError(
                         _('{assigned_object} already terminates {l2vpn}').format(
                         _('{assigned_object} already terminates {l2vpn}').format(
@@ -159,16 +159,11 @@ class L2VPNTermination(NetBoxModel):
 
 
     @property
     @property
     def assigned_object_parent(self):
     def assigned_object_parent(self):
-        obj_type = ObjectType.objects.get_for_model(self.assigned_object)
-        if obj_type.model == 'ipaddress':
-            # The device or virtual machine whose interface the IP address is assigned to, if any
-            interface = self.assigned_object.assigned_object
-            return getattr(interface, 'parent_object', None)
-        if obj_type.model == 'vminterface':
-            return self.assigned_object.virtual_machine
-        if obj_type.model == 'interface':
-            return self.assigned_object.device
-        return None
+        obj = self.assigned_object
+        if isinstance(obj, IPAddress):
+            # The interface the IP address is assigned to, if any
+            obj = obj.assigned_object
+        return getattr(obj, 'parent_object', None)
 
 
     @property
     @property
     def assigned_object_site(self):
     def assigned_object_site(self):

+ 1 - 1
netbox/vpn/tests/query_counts.json

@@ -12,7 +12,7 @@
   "l2vpn:api_list_objects": 14,
   "l2vpn:api_list_objects": 14,
   "l2vpn:list_objects_with_permission": 16,
   "l2vpn:list_objects_with_permission": 16,
   "l2vpntermination:api_list_objects": 15,
   "l2vpntermination:api_list_objects": 15,
-  "l2vpntermination:list_objects_with_permission": 21,
+  "l2vpntermination:list_objects_with_permission": 20,
   "tunnel:api_list_objects": 13,
   "tunnel:api_list_objects": 13,
   "tunnel:list_objects_with_permission": 17,
   "tunnel:list_objects_with_permission": 17,
   "tunnelgroup:api_list_objects": 12,
   "tunnelgroup:api_list_objects": 12,

+ 16 - 0
netbox/vpn/tests/test_api.py

@@ -1,3 +1,5 @@
+import json
+
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
@@ -711,3 +713,17 @@ class L2VPNTerminationTestCase(APIViewTestCases.APIViewTestCase):
         cls.bulk_update_invalid_data = {
         cls.bulk_update_invalid_data = {
             'l2vpn': 99999,
             'l2vpn': 99999,
         }
         }
+
+    def test_graphql_ip_address_l2vpn_terminations_filter(self):
+        """IP addresses can be filtered over GraphQL by the L2VPNs they terminate."""
+        self.add_permissions('ipam.view_ipaddress')
+        IPAddress.objects.create(address='192.0.2.2/32')
+        l2vpn = L2VPN.objects.get(name='L2VPN 2')
+        query = (
+            f'{{ ip_address_list(filters: {{l2vpn_terminations: {{l2vpn_id: "{l2vpn.pk}"}}}}) {{ address }} }}'
+        )
+        response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual([ip['address'] for ip in data['data']['ip_address_list']], ['192.0.2.1/32'])

+ 4 - 0
netbox/vpn/tests/test_filtersets.py

@@ -969,6 +969,10 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'ipaddress': ['192.0.2.1/32', '192.0.2.2/32']}
         params = {'ipaddress': ['192.0.2.1/32', '192.0.2.2/32']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ipaddress': ['192.0.2.1', '192.0.2.2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ipaddress': ['invalid']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
 
 
     def test_vlan_shared_name(self):
     def test_vlan_shared_name(self):
         # A VLAN name shared by several groups should resolve to a single filter value
         # A VLAN name shared by several groups should resolve to a single filter value

+ 44 - 1
netbox/vpn/tests/test_forms.py

@@ -1,6 +1,8 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
-from ipam.models import IPAddress
+from dcim.models import Interface
+from ipam.models import VRF, IPAddress
+from utilities.testing import create_test_device
 from vpn.choices import L2VPNTypeChoices
 from vpn.choices import L2VPNTypeChoices
 from vpn.forms import L2VPNTerminationForm, L2VPNTerminationImportForm
 from vpn.forms import L2VPNTerminationForm, L2VPNTerminationImportForm
 from vpn.models import L2VPN, L2VPNTermination
 from vpn.models import L2VPN, L2VPNTermination
@@ -33,7 +35,48 @@ class L2VPNTerminationImportFormTestCase(TestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
         L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VPLS)
         L2VPN.objects.create(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VPLS)
 
 
+        # The same address exists globally, in a VRF, and on a device's interface in another VRF
+        cls.vrfs = (
+            VRF.objects.create(name='VRF 1'),
+            VRF.objects.create(name='VRF 2'),
+        )
+        cls.device = create_test_device('Device 1')
+        interface = Interface.objects.create(device=cls.device, name='Interface 1', type='1000baset')
+        cls.ip_addresses = (
+            IPAddress.objects.create(address='192.0.2.1/32'),
+            IPAddress.objects.create(address='192.0.2.1/32', vrf=cls.vrfs[0]),
+            IPAddress.objects.create(address='192.0.2.1/32', vrf=cls.vrfs[1], assigned_object=interface),
+        )
+
     def test_missing_assigned_object(self):
     def test_missing_assigned_object(self):
         form = L2VPNTerminationImportForm(data={'l2vpn': 'L2VPN 1'}, instance=L2VPNTermination())
         form = L2VPNTerminationImportForm(data={'l2vpn': 'L2VPN 1'}, instance=L2VPNTermination())
         self.assertFalse(form.is_valid())
         self.assertFalse(form.is_valid())
         self.assertIn('must specify an interface, a VLAN, or an IP address', str(form.errors))
         self.assertIn('must specify an interface, a VLAN, or an IP address', str(form.errors))
+
+    def get_assigned_object(self, data):
+        form = L2VPNTerminationImportForm(data={'l2vpn': 'L2VPN 1', **data}, instance=L2VPNTermination())
+        self.assertTrue(form.is_valid(), form.errors)
+        return form.instance.assigned_object
+
+    def test_ipaddress_ambiguous(self):
+        form = L2VPNTerminationImportForm(
+            data={'l2vpn': 'L2VPN 1', 'ipaddress': '192.0.2.1/32'}, instance=L2VPNTermination()
+        )
+        self.assertFalse(form.is_valid())
+        self.assertIn('ipaddress', form.errors)
+
+    def test_ipaddress_vrf(self):
+        assigned_object = self.get_assigned_object({'ipaddress': '192.0.2.1/32', 'ipaddress_vrf': 'VRF 1'})
+        self.assertEqual(assigned_object, self.ip_addresses[1])
+
+    def test_ipaddress_device(self):
+        assigned_object = self.get_assigned_object({'ipaddress': '192.0.2.1/32', 'device': 'Device 1'})
+        self.assertEqual(assigned_object, self.ip_addresses[2])
+
+    def test_ipaddress_not_on_device(self):
+        form = L2VPNTerminationImportForm(
+            data={'l2vpn': 'L2VPN 1', 'ipaddress': '192.0.2.1/32', 'ipaddress_vrf': 'VRF 1', 'device': 'Device 1'},
+            instance=L2VPNTermination()
+        )
+        self.assertFalse(form.is_valid())
+        self.assertIn('ipaddress', form.errors)

+ 43 - 1
netbox/vpn/tests/test_views.py

@@ -1,7 +1,12 @@
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+from django.urls import reverse
+
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface
 from dcim.models import Interface
 from ipam.models import VLAN, IPAddress, RouteTarget
 from ipam.models import VLAN, IPAddress, RouteTarget
-from utilities.testing import ViewTestCases, create_tags, create_test_device
+from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine
+from virtualization.models import VMInterface
 from vpn.choices import *
 from vpn.choices import *
 from vpn.models import *
 from vpn.models import *
 
 
@@ -686,6 +691,43 @@ class L2VPNTerminationTestCase(
     def test_bulk_update_objects_with_permission(self):
     def test_bulk_update_objects_with_permission(self):
         pass
         pass
 
 
+    def test_list_objects_query_count(self):
+        """
+        Listing terminations should not need extra queries for each row to display its parent and site.
+        """
+        self.add_permissions('vpn.view_l2vpntermination')
+        self.user.config.set(
+            'tables.L2VPNTerminationTable.columns',
+            ['l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site'],
+            commit=True
+        )
+        device = create_test_device('Device 2')
+        virtual_machine = create_test_virtualmachine('Virtual Machine 1')
+
+        def get_queries(count):
+            l2vpn = L2VPN.objects.create(
+                name=f'L2VPN {count}x', slug=f'l2vpn-{count}x', type=L2VPNTypeChoices.TYPE_VPLS
+            )
+            for i in range(count):
+                interface = Interface.objects.create(device=device, name=f'{count}x {i}', type='1000baset')
+                vminterface = VMInterface.objects.create(virtual_machine=virtual_machine, name=f'{count}x {i}')
+                vlan = VLAN.objects.create(name=f'VLAN {count}x {i}', vid=100 * count + i, site=device.site)
+                for obj in (
+                    interface,
+                    vminterface,
+                    vlan,
+                    IPAddress.objects.create(address=f'198.51.{count}.{i}/32', assigned_object=interface),
+                    IPAddress.objects.create(address=f'203.0.{count}.{i}/32', assigned_object=vminterface),
+                ):
+                    L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=obj)
+            with CaptureQueriesContext(connection) as queries:
+                response = self.client.get(f"{reverse('vpn:l2vpntermination_list')}?l2vpn_id={l2vpn.pk}")
+            self.assertHttpStatus(response, 200)
+            self.assertContains(response, device.site.name)
+            return len(queries)
+
+        self.assertEqual(get_queries(1), get_queries(3))
+
     #
     #
     # Custom assertions
     # Custom assertions
     #
     #

+ 18 - 1
netbox/vpn/views.py

@@ -1,6 +1,9 @@
+from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from dcim.models import Interface
 from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from extras.ui.panels import CustomFieldsPanel, TagsPanel
+from ipam.models import VLAN, IPAddress
 from ipam.tables import RouteTargetTable
 from ipam.tables import RouteTargetTable
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.ui import actions, layout
 from netbox.ui import actions, layout
@@ -14,6 +17,7 @@ from netbox.ui.panels import (
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.views import GetRelatedModelsMixin, register_model_view
 from utilities.views import GetRelatedModelsMixin, register_model_view
+from virtualization.models import VMInterface
 
 
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *
@@ -761,7 +765,20 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
 
 
 @register_model_view(L2VPNTermination, 'list', path='', detail=False)
 @register_model_view(L2VPNTermination, 'list', path='', detail=False)
 class L2VPNTerminationListView(generic.ObjectListView):
 class L2VPNTerminationListView(generic.ObjectListView):
-    queryset = L2VPNTermination.objects.all()
+    # Fetch each terminating object's parent and site, which the table may display
+    queryset = L2VPNTermination.objects.prefetch_related(
+        GenericPrefetch('assigned_object', [
+            Interface.objects.select_related('device__site'),
+            VMInterface.objects.select_related('virtual_machine__site'),
+            VLAN.objects.select_related('site'),
+            IPAddress.objects.prefetch_related(
+                GenericPrefetch('assigned_object', [
+                    Interface.objects.select_related('device__site'),
+                    VMInterface.objects.select_related('virtual_machine__site'),
+                ])
+            ),
+        ])
+    )
     table = tables.L2VPNTerminationTable
     table = tables.L2VPNTerminationTable
     filterset = filtersets.L2VPNTerminationFilterSet
     filterset = filtersets.L2VPNTerminationFilterSet
     filterset_form = forms.L2VPNTerminationFilterForm
     filterset_form = forms.L2VPNTerminationFilterForm