Răsfoiți Sursa

Merge pull request #15369 from netbox-community/15237-audit-filtersets

Closes #15237: Add tests for missing filters
Jeremy Stretch 1 an în urmă
părinte
comite
2d4295e2ed

+ 23 - 6
netbox/circuits/filtersets.py

@@ -67,7 +67,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -95,7 +95,7 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ProviderAccount
         model = ProviderAccount
-        fields = ['id', 'name', 'account', 'description']
+        fields = ('id', 'name', 'account', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -122,7 +122,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ProviderNetwork
         model = ProviderNetwork
-        fields = ['id', 'name', 'service_id', 'description']
+        fields = ('id', 'name', 'service_id', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -139,7 +139,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
-        fields = ['id', 'name', 'slug', 'color', 'description']
+        fields = ('id', 'name', 'slug', 'color', 'description')
 
 
 
 
 class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
 class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
@@ -158,6 +158,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         queryset=ProviderAccount.objects.all(),
         queryset=ProviderAccount.objects.all(),
         label=_('Provider account (ID)'),
         label=_('Provider account (ID)'),
     )
     )
+    provider_account = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_account__account',
+        queryset=Provider.objects.all(),
+        to_field_name='account',
+        label=_('Provider account (account)'),
+    )
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
     provider_network_id = django_filters.ModelMultipleChoiceFilter(
         field_name='terminations__provider_network',
         field_name='terminations__provider_network',
         queryset=ProviderNetwork.objects.all(),
         queryset=ProviderNetwork.objects.all(),
@@ -214,10 +220,18 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
     )
     )
+    termination_a_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CircuitTermination.objects.all(),
+        label=_('Termination A (ID)'),
+    )
+    termination_z_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=CircuitTermination.objects.all(),
+        label=_('Termination A (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate']
+        fields = ('id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -258,7 +272,10 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
-        fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end']
+        fields = (
+            'id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'mark_connected',
+            'pp_info', 'cable_end',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 1 - 0
netbox/circuits/tests/test_filtersets.py

@@ -330,6 +330,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
 class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
 class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CircuitTermination.objects.all()
     queryset = CircuitTermination.objects.all()
     filterset = CircuitTerminationFilterSet
     filterset = CircuitTerminationFilterSet
+    ignore_fields = ('cable',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 3 - 5
netbox/core/filtersets.py

@@ -28,7 +28,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = DataSource
         model = DataSource
-        fields = ('id', 'name', 'enabled', 'description')
+        fields = ('id', 'name', 'enabled', 'description', 'source_url', 'last_synced')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -115,7 +115,7 @@ class JobFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = Job
         model = Job
-        fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user')
+        fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -134,9 +134,7 @@ class ConfigRevisionFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConfigRevision
         model = ConfigRevision
-        fields = [
-            'id',
-        ]
+        fields = ('id', 'created', 'comment')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 2 - 0
netbox/core/tests/test_filtersets.py

@@ -10,6 +10,7 @@ from ..models import *
 class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DataSource.objects.all()
     queryset = DataSource.objects.all()
     filterset = DataSourceFilterSet
     filterset = DataSourceFilterSet
+    ignore_fields = ('ignore_rules', 'parameters')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -70,6 +71,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DataFile.objects.all()
     queryset = DataFile.objects.all()
     filterset = DataFileFilterSet
     filterset = DataFileFilterSet
+    ignore_fields = ('data',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 159 - 64
netbox/dcim/filtersets.py

@@ -18,11 +18,12 @@ from tenancy.models import *
 from utilities.choices import ColorChoices
 from utilities.choices import ColorChoices
 from utilities.filters import (
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
     ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
-    TreeNodeMultipleChoiceFilter,
+    NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from vpn.models import L2VPN
 from vpn.models import L2VPN
 from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
 from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
+from wireless.models import WirelessLAN, WirelessLink
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
 from .models import *
 from .models import *
@@ -105,7 +106,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
@@ -135,7 +136,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
 class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
@@ -178,12 +179,11 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
         queryset=ASN.objects.all(),
         queryset=ASN.objects.all(),
         label=_('AS (ID)'),
         label=_('AS (ID)'),
     )
     )
+    time_zone = MultiValueCharFilter()
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = (
-            'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description'
-        )
+        fields = ('id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -270,7 +270,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
 
 
     class Meta:
     class Meta:
         model = Location
         model = Location
-        fields = ['id', 'name', 'slug', 'status', 'description']
+        fields = ('id', 'name', 'slug', 'status', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -285,7 +285,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
-        fields = ['id', 'name', 'slug', 'color', 'description']
+        fields = ('id', 'name', 'slug', 'color', 'description')
 
 
 
 
 class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
 class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
@@ -364,10 +364,10 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = [
+        fields = (
             'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
             'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
             'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
             'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
-        ]
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -447,10 +447,14 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='username',
         to_field_name='username',
         label=_('User (name)'),
         label=_('User (name)'),
     )
     )
+    unit = NumericArrayFilter(
+        field_name='units',
+        lookup_expr='contains'
+    )
 
 
     class Meta:
     class Meta:
         model = RackReservation
         model = RackReservation
-        fields = ['id', 'created', 'description']
+        fields = ('id', 'created', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -467,7 +471,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class DeviceTypeFilterSet(NetBoxModelFilterSet):
 class DeviceTypeFilterSet(NetBoxModelFilterSet):
@@ -538,10 +542,22 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
-        fields = [
+        fields = (
             'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
             'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
             'subdevice_role', 'airflow', 'weight', 'weight_unit', 'description',
             'subdevice_role', 'airflow', 'weight', 'weight_unit', 'description',
-        ]
+
+            # Counters
+            'console_port_template_count',
+            'console_server_port_template_count',
+            'power_port_template_count',
+            'power_outlet_template_count',
+            'interface_template_count',
+            'front_port_template_count',
+            'rear_port_template_count',
+            'device_bay_template_count',
+            'module_bay_template_count',
+            'inventory_item_template_count',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -635,7 +651,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
-        fields = ['id', 'model', 'part_number', 'weight', 'weight_unit', 'description']
+        fields = ('id', 'model', 'part_number', 'weight', 'weight_unit', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -675,12 +691,15 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    devicetype_id = django_filters.ModelMultipleChoiceFilter(
+    device_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         field_name='device_type_id',
         field_name='device_type_id',
         label=_('Device type (ID)'),
         label=_('Device type (ID)'),
     )
     )
 
 
+    # TODO: Remove in v4.1
+    devicetype_id = device_type_id
+
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
@@ -691,32 +710,35 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet):
 
 
 
 
 class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
 class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
-    moduletype_id = django_filters.ModelMultipleChoiceFilter(
+    module_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ModuleType.objects.all(),
         queryset=ModuleType.objects.all(),
         field_name='module_type_id',
         field_name='module_type_id',
         label=_('Module type (ID)'),
         label=_('Module type (ID)'),
     )
     )
 
 
+    # TODO: Remove in v4.1
+    moduletype_id = module_type_id
+
 
 
 class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
-        fields = ['id', 'name', 'type', 'description']
+        fields = ('id', 'name', 'label', 'type', 'description')
 
 
 
 
 class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
-        fields = ['id', 'name', 'type', 'description']
+        fields = ('id', 'name', 'label', 'type', 'description')
 
 
 
 
 class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
-        fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
+        fields = ('id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
 
 
 
 
 class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -724,10 +746,14 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
         null_value=None
         null_value=None
     )
     )
+    power_port_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=PowerPortTemplate.objects.all(),
+        label=_('Power port (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
-        fields = ['id', 'name', 'type', 'feed_leg', 'description']
+        fields = ('id', 'name', 'label', 'type', 'feed_leg', 'description')
 
 
 
 
 class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -751,7 +777,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ['id', 'name', 'type', 'enabled', 'mgmt_only', 'description']
+        fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')
 
 
 
 
 class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -759,10 +785,13 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None
     )
     )
+    rear_port_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RearPort.objects.all()
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
-        fields = ['id', 'name', 'type', 'color', 'description']
+        fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
 
 
 
 
 class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
@@ -773,21 +802,21 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
-        fields = ['id', 'name', 'type', 'color', 'positions', 'description']
+        fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
 
 
 
 
 class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ModuleBayTemplate
         model = ModuleBayTemplate
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'label', 'position', 'description')
 
 
 
 
 class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'label', 'description')
 
 
 
 
 class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
@@ -820,7 +849,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
 
 
     class Meta:
     class Meta:
         model = InventoryItemTemplate
         model = InventoryItemTemplate
-        fields = ['id', 'name', 'label', 'part_id', 'description']
+        fields = ('id', 'name', 'label', 'part_id', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -841,7 +870,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description']
+        fields = ('id', 'name', 'slug', 'color', 'vm_role', 'description')
 
 
 
 
 class PlatformFilterSet(OrganizationalModelFilterSet):
 class PlatformFilterSet(OrganizationalModelFilterSet):
@@ -867,7 +896,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
     @extend_schema_field(OpenApiTypes.STR)
     @extend_schema_field(OpenApiTypes.STR)
     def get_for_device_type(self, queryset, name, value):
     def get_for_device_type(self, queryset, name, value):
@@ -979,6 +1008,11 @@ class DeviceFilterSet(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label=_('Rack (ID)'),
         label=_('Rack (ID)'),
     )
     )
+    parent_bay_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent_bay',
+        queryset=DeviceBay.objects.all(),
+        label=_('Parent bay (ID)'),
+    )
     cluster_id = django_filters.ModelMultipleChoiceFilter(
     cluster_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         label=_('VM cluster (ID)'),
         label=_('VM cluster (ID)'),
@@ -1068,10 +1102,22 @@ class DeviceFilterSet(
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
-        fields = [
+        fields = (
             'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority',
             'id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority',
             'description',
             'description',
-        ]
+
+            # Counters
+            'console_port_count',
+            'console_server_port_count',
+            'power_port_count',
+            'power_outlet_count',
+            'interface_count',
+            'front_port_count',
+            'rear_port_count',
+            'device_bay_count',
+            'module_bay_count',
+            'inventory_item_count',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1134,24 +1180,29 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, Prim
     device_id = django_filters.ModelMultipleChoiceFilter(
     device_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device',
         field_name='device',
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
-        label='VDC (ID)',
+        label=_('VDC (ID)')
     )
     )
     device = django_filters.ModelMultipleChoiceFilter(
     device = django_filters.ModelMultipleChoiceFilter(
         field_name='device',
         field_name='device',
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
-        label='Device model',
+        label=_('Device model')
+    )
+    interface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='interfaces',
+        queryset=Interface.objects.all(),
+        label=_('Interface (ID)')
     )
     )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=VirtualDeviceContextStatusChoices
         choices=VirtualDeviceContextStatusChoices
     )
     )
     has_primary_ip = django_filters.BooleanFilter(
     has_primary_ip = django_filters.BooleanFilter(
         method='_has_primary_ip',
         method='_has_primary_ip',
-        label='Has a primary IP',
+        label=_('Has a primary IP')
     )
     )
 
 
     class Meta:
     class Meta:
         model = VirtualDeviceContext
         model = VirtualDeviceContext
-        fields = ['id', 'device', 'name', 'description']
+        fields = ('id', 'device', 'name', 'identifier', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1217,7 +1268,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Module
         model = Module
-        fields = ['id', 'status', 'asset_tag', 'description']
+        fields = ('id', 'status', 'asset_tag', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1361,6 +1412,10 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
 
 
 
 
 class CabledObjectFilterSet(django_filters.FilterSet):
 class CabledObjectFilterSet(django_filters.FilterSet):
+    cable_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Cable.objects.all(),
+        label=_('Cable (ID)'),
+    )
     cabled = django_filters.BooleanFilter(
     cabled = django_filters.BooleanFilter(
         field_name='cable',
         field_name='cable',
         lookup_expr='isnull',
         lookup_expr='isnull',
@@ -1402,7 +1457,7 @@ class ConsolePortFilterSet(
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
-        fields = ['id', 'name', 'label', 'description', 'cable_end']
+        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
 
 
 
 
 class ConsoleServerPortFilterSet(
 class ConsoleServerPortFilterSet(
@@ -1418,7 +1473,7 @@ class ConsoleServerPortFilterSet(
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ['id', 'name', 'label', 'description', 'cable_end']
+        fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end')
 
 
 
 
 class PowerPortFilterSet(
 class PowerPortFilterSet(
@@ -1434,7 +1489,9 @@ class PowerPortFilterSet(
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
-        fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end']
+        fields = (
+            'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
+        )
 
 
 
 
 class PowerOutletFilterSet(
 class PowerOutletFilterSet(
@@ -1451,10 +1508,16 @@ class PowerOutletFilterSet(
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
         null_value=None
         null_value=None
     )
     )
+    power_port_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=PowerPort.objects.all(),
+        label=_('Power port (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
-        fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
+        fields = (
+            'id', 'name', 'label', 'feed_leg', 'description', 'mark_connected', 'cable_end',
+        )
 
 
 
 
 class CommonInterfaceFilterSet(django_filters.FilterSet):
 class CommonInterfaceFilterSet(django_filters.FilterSet):
@@ -1569,27 +1632,37 @@ class InterfaceFilterSet(
     vdc_id = django_filters.ModelMultipleChoiceFilter(
     vdc_id = django_filters.ModelMultipleChoiceFilter(
         field_name='vdcs',
         field_name='vdcs',
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),
-        label='Virtual Device Context',
+        label=_('Virtual Device Context')
     )
     )
     vdc_identifier = django_filters.ModelMultipleChoiceFilter(
     vdc_identifier = django_filters.ModelMultipleChoiceFilter(
         field_name='vdcs__identifier',
         field_name='vdcs__identifier',
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),
         to_field_name='identifier',
         to_field_name='identifier',
-        label='Virtual Device Context (Identifier)',
+        label=_('Virtual Device Context (Identifier)')
     )
     )
     vdc = django_filters.ModelMultipleChoiceFilter(
     vdc = django_filters.ModelMultipleChoiceFilter(
         field_name='vdcs__name',
         field_name='vdcs__name',
         queryset=VirtualDeviceContext.objects.all(),
         queryset=VirtualDeviceContext.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        label='Virtual Device Context',
+        label=_('Virtual Device Context')
+    )
+    wireless_lan_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='wireless_lans',
+        queryset=WirelessLAN.objects.all(),
+        label=_('Wireless LAN')
+    )
+    wireless_link_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=WirelessLink.objects.all(),
+        label=_('Wireless link')
     )
     )
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = [
+        fields = (
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
             'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
-            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
-        ]
+            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
+            'cable_id', 'cable_end',
+        )
 
 
     def filter_virtual_chassis_member(self, queryset, name, value):
     def filter_virtual_chassis_member(self, queryset, name, value):
         try:
         try:
@@ -1618,10 +1691,15 @@ class FrontPortFilterSet(
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None
     )
     )
+    rear_port_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RearPort.objects.all()
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
-        fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end']
+        fields = (
+            'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
+        )
 
 
 
 
 class RearPortFilterSet(
 class RearPortFilterSet(
@@ -1636,21 +1714,38 @@ class RearPortFilterSet(
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
-        fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end']
+        fields = (
+            'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
+        )
 
 
 
 
 class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
+    installed_module_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='installed_module',
+        queryset=ModuleBay.objects.all(),
+        label=_('Installed module (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = ModuleBay
         model = ModuleBay
-        fields = ['id', 'name', 'label', 'description']
+        fields = ('id', 'name', 'label', 'position', 'description')
 
 
 
 
 class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
+    installed_device_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Device.objects.all(),
+        label=_('Installed device (ID)'),
+    )
+    installed_device = django_filters.ModelMultipleChoiceFilter(
+        field_name='installed_device__name',
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        label=_('Installed device (name)'),
+    )
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
-        fields = ['id', 'name', 'label', 'description']
+        fields = ('id', 'name', 'label', 'description')
 
 
 
 
 class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
@@ -1686,7 +1781,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered']
+        fields = ('id', 'name', 'label', 'part_id', 'asset_tag', 'description', 'discovered')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1705,7 +1800,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = InventoryItemRole
         model = InventoryItemRole
-        fields = ['id', 'name', 'slug', 'color', 'description']
+        fields = ('id', 'name', 'slug', 'color', 'description')
 
 
 
 
 class VirtualChassisFilterSet(NetBoxModelFilterSet):
 class VirtualChassisFilterSet(NetBoxModelFilterSet):
@@ -1770,7 +1865,7 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
-        fields = ['id', 'domain', 'name', 'description']
+        fields = ('id', 'domain', 'name', 'description', 'member_count')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1875,7 +1970,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Cable
         model = Cable
-        fields = ['id', 'label', 'length', 'length_unit', 'description']
+        fields = ('id', 'label', 'length', 'length_unit', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1953,12 +2048,12 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
         return self.filter_by_termination_object(queryset, CircuitTermination, value)
         return self.filter_by_termination_object(queryset, CircuitTermination, value)
 
 
 
 
-class CableTerminationFilterSet(BaseFilterSet):
+class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
     termination_type = ContentTypeFilter()
     termination_type = ContentTypeFilter()
 
 
     class Meta:
     class Meta:
         model = CableTermination
         model = CableTermination
-        fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id']
+        fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
 
 
 
 
 class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@@ -2007,7 +2102,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPanel
         model = PowerPanel
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -2073,10 +2168,10 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
 
 
     class Meta:
     class Meta:
         model = PowerFeed
         model = PowerFeed
-        fields = [
-            'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end',
-            'description',
-        ]
+        fields = (
+            'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
+            'available_power', 'mark_connected', 'cable_end', 'description',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -2135,18 +2230,18 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
-        fields = ['name']
+        fields = ('name',)
 
 
 
 
 class PowerConnectionFilterSet(ConnectionFilterSet):
 class PowerConnectionFilterSet(ConnectionFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
-        fields = ['name']
+        fields = ('name',)
 
 
 
 
 class InterfaceConnectionFilterSet(ConnectionFilterSet):
 class InterfaceConnectionFilterSet(ConnectionFilterSet):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = []
+        fields = tuple()

+ 1 - 1
netbox/dcim/forms/filtersets.py

@@ -754,7 +754,7 @@ class DeviceFilterForm(
     )
     )
     has_oob_ip = forms.NullBooleanField(
     has_oob_ip = forms.NullBooleanField(
         required=False,
         required=False,
-        label='Has an OOB IP',
+        label=_('Has an OOB IP'),
         widget=forms.Select(
         widget=forms.Select(
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )

+ 32 - 15
netbox/dcim/tests/test_filtersets.py

@@ -196,6 +196,7 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
 class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
 class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
     filterset = SiteFilterSet
     filterset = SiteFilterSet
+    ignore_fields = ('physical_address', 'shipping_address')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -467,6 +468,7 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Rack.objects.all()
     queryset = Rack.objects.all()
     filterset = RackFilterSet
     filterset = RackFilterSet
+    ignore_fields = ('units',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -726,6 +728,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackReservation.objects.all()
     queryset = RackReservation.objects.all()
     filterset = RackReservationFilterSet
     filterset = RackReservationFilterSet
+    ignore_fields = ('units',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -889,6 +892,7 @@ class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceType.objects.all()
     queryset = DeviceType.objects.all()
     filterset = DeviceTypeFilterSet
     filterset = DeviceTypeFilterSet
+    ignore_fields = ('front_image', 'rear_image')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1880,6 +1884,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Device.objects.all()
     queryset = Device.objects.all()
     filterset = DeviceFilterSet
     filterset = DeviceFilterSet
+    ignore_fields = ('local_context_data', 'oob_ip', 'primary_ip4', 'primary_ip6', 'vc_master_for')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -2332,6 +2337,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Module.objects.all()
     queryset = Module.objects.all()
     filterset = ModuleFilterSet
     filterset = ModuleFilterSet
+    ignore_fields = ('local_context_data',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -3229,6 +3235,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
     filterset = InterfaceFilterSet
+    ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -5332,6 +5339,7 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualDeviceContext.objects.all()
     queryset = VirtualDeviceContext.objects.all()
     filterset = VirtualDeviceContextFilterSet
     filterset = VirtualDeviceContextFilterSet
+    ignore_fields = ('primary_ip4', 'primary_ip6')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -5401,15 +5409,22 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         VirtualDeviceContext.objects.bulk_create(vdcs)
         VirtualDeviceContext.objects.bulk_create(vdcs)
 
 
         interfaces = (
         interfaces = (
-            Interface(device=devices[0], name='Interface 1', type='virtual'),
-            Interface(device=devices[0], name='Interface 2', type='virtual'),
+            Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL),
         )
         )
         Interface.objects.bulk_create(interfaces)
         Interface.objects.bulk_create(interfaces)
-
         interfaces[0].vdcs.set([vdcs[0]])
         interfaces[0].vdcs.set([vdcs[0]])
         interfaces[1].vdcs.set([vdcs[1]])
         interfaces[1].vdcs.set([vdcs[1]])
+        interfaces[2].vdcs.set([vdcs[2]])
+        interfaces[3].vdcs.set([vdcs[3]])
+        interfaces[4].vdcs.set([vdcs[4]])
+        interfaces[5].vdcs.set([vdcs[5]])
 
 
-        addresses = (
+        ip_addresses = (
             IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
             IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
             IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
             IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
             IPAddress(assigned_object=None, address='10.1.1.3/24'),
             IPAddress(assigned_object=None, address='10.1.1.3/24'),
@@ -5417,13 +5432,12 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
             IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
             IPAddress(assigned_object=None, address='2001:db8::3/64'),
             IPAddress(assigned_object=None, address='2001:db8::3/64'),
         )
         )
-        IPAddress.objects.bulk_create(addresses)
-
-        vdcs[0].primary_ip4 = addresses[0]
-        vdcs[0].primary_ip6 = addresses[3]
+        IPAddress.objects.bulk_create(ip_addresses)
+        vdcs[0].primary_ip4 = ip_addresses[0]
+        vdcs[0].primary_ip6 = ip_addresses[3]
         vdcs[0].save()
         vdcs[0].save()
-        vdcs[1].primary_ip4 = addresses[1]
-        vdcs[1].primary_ip6 = addresses[4]
+        vdcs[1].primary_ip4 = ip_addresses[1]
+        vdcs[1].primary_ip6 = ip_addresses[4]
         vdcs[1].save()
         vdcs[1].save()
 
 
     def test_q(self):
     def test_q(self):
@@ -5431,8 +5445,11 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
     def test_device(self):
     def test_device(self):
-        params = {'device': ['Device 1', 'Device 2']}
+        devices = Device.objects.filter(name__in=['Device 1', 'Device 2'])
+        params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_status(self):
     def test_status(self):
         params = {'status': ['active']}
         params = {'status': ['active']}
@@ -5442,10 +5459,10 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         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)
 
 
-    def test_device_id(self):
-        devices = Device.objects.filter(name__in=['Device 1', 'Device 2'])
-        params = {'device_id': [devices[0].pk, devices[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+    def test_interface(self):
+        interfaces = Interface.objects.filter(name__in=['Interface 1', 'Interface 3'])
+        params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_has_primary_ip(self):
     def test_has_primary_ip(self):
         params = {'has_primary_ip': True}
         params = {'has_primary_ip': True}

+ 65 - 50
netbox/extras/filtersets.py

@@ -40,12 +40,14 @@ class ScriptFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
+    module_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ScriptModule.objects.all(),
+        label=_('Script module (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = Script
         model = Script
-        fields = [
-            'id', 'name',
-        ]
+        fields = ('id', 'name', 'is_executable')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -69,10 +71,10 @@ class WebhookFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Webhook
         model = Webhook
-        fields = [
+        fields = (
             'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification',
             'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification',
             'ca_file_path', 'description',
             'ca_file_path', 'description',
-        ]
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -89,8 +91,9 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    object_type_id = MultiValueNumberFilter(
-        field_name='object_types__id'
+    object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.all(),
+        field_name='object_types'
     )
     )
     object_type = ContentTypeFilter(
     object_type = ContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
@@ -103,10 +106,10 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = EventRule
         model = EventRule
-        fields = [
+        fields = (
             'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled',
             'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled',
             'action_type', 'description',
             'action_type', 'description',
-        ]
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -118,7 +121,7 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
         )
         )
 
 
 
 
-class CustomFieldFilterSet(BaseFilterSet):
+class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
@@ -126,14 +129,16 @@ class CustomFieldFilterSet(BaseFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=CustomFieldTypeChoices
         choices=CustomFieldTypeChoices
     )
     )
-    object_type_id = MultiValueNumberFilter(
-        field_name='object_types__id'
+    object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.all(),
+        field_name='object_types'
     )
     )
     object_type = ContentTypeFilter(
     object_type = ContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
     )
     )
-    related_object_type_id = MultiValueNumberFilter(
-        field_name='related_object_type__id'
+    related_object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.all(),
+        field_name='related_object_type'
     )
     )
     related_object_type = ContentTypeFilter()
     related_object_type = ContentTypeFilter()
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
@@ -147,10 +152,11 @@ class CustomFieldFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField
-        fields = [
-            'id', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable',
-            'weight', 'is_cloneable', 'description',
-        ]
+        fields = (
+            'id', 'name', 'label', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible',
+            'ui_editable', 'weight', 'is_cloneable', 'description', 'validation_minimum', 'validation_maximum',
+            'validation_regex',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -163,7 +169,7 @@ class CustomFieldFilterSet(BaseFilterSet):
         )
         )
 
 
 
 
-class CustomFieldChoiceSetFilterSet(BaseFilterSet):
+class CustomFieldChoiceSetFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
@@ -174,9 +180,9 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = CustomFieldChoiceSet
         model = CustomFieldChoiceSet
-        fields = [
+        fields = (
             'id', 'name', 'description', 'base_choices', 'order_alphabetically',
             'id', 'name', 'description', 'base_choices', 'order_alphabetically',
-        ]
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -191,13 +197,14 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
         return queryset.filter(extra_choices__overlap=value)
         return queryset.filter(extra_choices__overlap=value)
 
 
 
 
-class CustomLinkFilterSet(BaseFilterSet):
+class CustomLinkFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    object_type_id = MultiValueNumberFilter(
-        field_name='object_types__id'
+    object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.all(),
+        field_name='object_types'
     )
     )
     object_type = ContentTypeFilter(
     object_type = ContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
@@ -205,9 +212,9 @@ class CustomLinkFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = CustomLink
         model = CustomLink
-        fields = [
-            'id', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
-        ]
+        fields = (
+            'id', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', 'button_class',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -220,13 +227,14 @@ class CustomLinkFilterSet(BaseFilterSet):
         )
         )
 
 
 
 
-class ExportTemplateFilterSet(BaseFilterSet):
+class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    object_type_id = MultiValueNumberFilter(
-        field_name='object_types__id'
+    object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.all(),
+        field_name='object_types'
     )
     )
     object_type = ContentTypeFilter(
     object_type = ContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
@@ -242,7 +250,10 @@ class ExportTemplateFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = ExportTemplate
         model = ExportTemplate
-        fields = ['id', 'name', 'description', 'data_synced']
+        fields = (
+            'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled',
+            'data_synced',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -253,13 +264,14 @@ class ExportTemplateFilterSet(BaseFilterSet):
         )
         )
 
 
 
 
-class SavedFilterFilterSet(BaseFilterSet):
+class SavedFilterFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    object_type_id = MultiValueNumberFilter(
-        field_name='object_types__id'
+    object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.all(),
+        field_name='object_types'
     )
     )
     object_type = ContentTypeFilter(
     object_type = ContentTypeFilter(
         field_name='object_types'
         field_name='object_types'
@@ -280,7 +292,7 @@ class SavedFilterFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = SavedFilter
         model = SavedFilter
-        fields = ['id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight']
+        fields = ('id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -321,20 +333,19 @@ class BookmarkFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = Bookmark
         model = Bookmark
-        fields = ['id', 'object_id']
+        fields = ('id', 'object_id')
 
 
 
 
-class ImageAttachmentFilterSet(BaseFilterSet):
+class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
-    created = django_filters.DateTimeFilter()
     object_type = ContentTypeFilter()
     object_type = ContentTypeFilter()
 
 
     class Meta:
     class Meta:
         model = ImageAttachment
         model = ImageAttachment
-        fields = ['id', 'object_type_id', 'object_id', 'name']
+        fields = ('id', 'object_type_id', 'object_id', 'name', 'image_width', 'image_height')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -364,7 +375,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = JournalEntry
         model = JournalEntry
-        fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind']
+        fields = ('id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -389,7 +400,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
-        fields = ['id', 'name', 'slug', 'color', 'description', 'object_types']
+        fields = ('id', 'name', 'slug', 'color', 'description', 'object_types')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -486,12 +497,12 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         label=_('Device type'),
         label=_('Device type'),
     )
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
+    device_role_id = django_filters.ModelMultipleChoiceFilter(
         field_name='roles',
         field_name='roles',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         label=_('Role'),
         label=_('Role'),
     )
     )
-    role = django_filters.ModelMultipleChoiceFilter(
+    device_role = django_filters.ModelMultipleChoiceFilter(
         field_name='roles__slug',
         field_name='roles__slug',
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
@@ -577,9 +588,13 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         label=_('Data file (ID)'),
         label=_('Data file (ID)'),
     )
     )
 
 
+    # TODO: Remove in v4.1
+    role = device_role
+    role_id = device_role_id
+
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
-        fields = ['id', 'name', 'is_active', 'data_synced', 'description']
+        fields = ('id', 'name', 'is_active', 'description', 'weight', 'auto_sync_enabled', 'data_synced')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -591,7 +606,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         )
         )
 
 
 
 
-class ConfigTemplateFilterSet(BaseFilterSet):
+class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
@@ -608,7 +623,7 @@ class ConfigTemplateFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConfigTemplate
         model = ConfigTemplate
-        fields = ['id', 'name', 'description', 'data_synced']
+        fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -656,10 +671,10 @@ class ObjectChangeFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = ObjectChange
         model = ObjectChange
-        fields = [
+        fields = (
             'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
             'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
-            'object_repr',
-        ]
+            'related_object_type', 'related_object_id', 'object_repr',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -682,7 +697,7 @@ class ObjectTypeFilterSet(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = ObjectType
         model = ObjectType
-        fields = ['id', 'app_label', 'model']
+        fields = ('id', 'app_label', 'model')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 107 - 16
netbox/extras/tests/test_filtersets.py

@@ -23,9 +23,10 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 User = get_user_model()
 User = get_user_model()
 
 
 
 
-class CustomFieldTestCase(TestCase, BaseFilterSetTests):
+class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomField.objects.all()
     queryset = CustomField.objects.all()
     filterset = CustomFieldFilterSet
     filterset = CustomFieldFilterSet
+    ignore_fields = ('default',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -155,9 +156,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
+class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomFieldChoiceSet.objects.all()
     queryset = CustomFieldChoiceSet.objects.all()
     filterset = CustomFieldChoiceSetFilterSet
     filterset = CustomFieldChoiceSetFilterSet
+    ignore_fields = ('extra_choices',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -188,6 +190,7 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
 class WebhookTestCase(TestCase, BaseFilterSetTests):
 class WebhookTestCase(TestCase, BaseFilterSetTests):
     queryset = Webhook.objects.all()
     queryset = Webhook.objects.all()
     filterset = WebhookFilterSet
     filterset = WebhookFilterSet
+    ignore_fields = ('additional_headers', 'body_template')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -252,6 +255,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
 class EventRuleTestCase(TestCase, BaseFilterSetTests):
 class EventRuleTestCase(TestCase, BaseFilterSetTests):
     queryset = EventRule.objects.all()
     queryset = EventRule.objects.all()
     filterset = EventRuleFilterSet
     filterset = EventRuleFilterSet
+    ignore_fields = ('action_data', 'conditions')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -405,7 +409,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class CustomLinkTestCase(TestCase, BaseFilterSetTests):
+class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomLink.objects.all()
     queryset = CustomLink.objects.all()
     filterset = CustomLinkFilterSet
     filterset = CustomLinkFilterSet
 
 
@@ -474,9 +478,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
-class SavedFilterTestCase(TestCase, BaseFilterSetTests):
+class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = SavedFilter.objects.all()
     queryset = SavedFilter.objects.all()
     filterset = SavedFilterFilterSet
     filterset = SavedFilterFilterSet
+    ignore_fields = ('parameters',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -647,9 +652,10 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
 
 
-class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
+class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
     filterset = ExportTemplateFilterSet
+    ignore_fields = ('template_code', 'data_path')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -683,9 +689,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
+class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
     filterset = ImageAttachmentFilterSet
     filterset = ImageAttachmentFilterSet
+    ignore_fields = ('image',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -760,12 +767,6 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
         }
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-    def test_created(self):
-        pk_list = self.queryset.values_list('pk', flat=True)[:2]
-        self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc))
-        params = {'created': '2021-01-01T00:00:00'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-
 
 
 class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
 class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = JournalEntry.objects.all()
     queryset = JournalEntry.objects.all()
@@ -873,6 +874,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
     filterset = ConfigContextFilterSet
+    ignore_fields = ('data', 'data_path')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1041,11 +1043,11 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_role(self):
+    def test_device_role(self):
         device_roles = DeviceRole.objects.all()[:2]
         device_roles = DeviceRole.objects.all()[:2]
-        params = {'role_id': [device_roles[0].pk, device_roles[1].pk]}
+        params = {'device_role_id': [device_roles[0].pk, device_roles[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'role': [device_roles[0].slug, device_roles[1].slug]}
+        params = {'device_role': [device_roles[0].slug, device_roles[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_platform(self):
     def test_platform(self):
@@ -1096,9 +1098,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class ConfigTemplateTestCase(TestCase, BaseFilterSetTests):
+class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigTemplate.objects.all()
     queryset = ConfigTemplate.objects.all()
     filterset = ConfigTemplateFilterSet
     filterset = ConfigTemplateFilterSet
+    ignore_fields = ('template_code', 'environment_params', 'data_path')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1125,6 +1128,93 @@ class ConfigTemplateTestCase(TestCase, BaseFilterSetTests):
 class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
 class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tag.objects.all()
     queryset = Tag.objects.all()
     filterset = TagFilterSet
     filterset = TagFilterSet
+    ignore_fields = (
+        'object_types',
+
+        # Reverse relationships (to tagged models) we can ignore
+        'aggregate',
+        'asn',
+        'asnrange',
+        'cable',
+        'circuit',
+        'circuittermination',
+        'circuittype',
+        'cluster',
+        'clustergroup',
+        'clustertype',
+        'configtemplate',
+        'consoleport',
+        'consoleserverport',
+        'contact',
+        'contactassignment',
+        'contactgroup',
+        'contactrole',
+        'datasource',
+        'device',
+        'devicebay',
+        'devicerole',
+        'devicetype',
+        'dummymodel',  # From dummy_plugin
+        'eventrule',
+        'fhrpgroup',
+        'frontport',
+        'ikepolicy',
+        'ikeproposal',
+        'interface',
+        'inventoryitem',
+        'inventoryitemrole',
+        'ipaddress',
+        'iprange',
+        'ipsecpolicy',
+        'ipsecprofile',
+        'ipsecproposal',
+        'journalentry',
+        'l2vpn',
+        'l2vpntermination',
+        'location',
+        'manufacturer',
+        'module',
+        'modulebay',
+        'moduletype',
+        'platform',
+        'powerfeed',
+        'poweroutlet',
+        'powerpanel',
+        'powerport',
+        'prefix',
+        'provider',
+        'provideraccount',
+        'providernetwork',
+        'rack',
+        'rackreservation',
+        'rackrole',
+        'rearport',
+        'region',
+        'rir',
+        'role',
+        'routetarget',
+        'service',
+        'servicetemplate',
+        'site',
+        'sitegroup',
+        'tenant',
+        'tenantgroup',
+        'tunnel',
+        'tunnelgroup',
+        'tunneltermination',
+        'virtualchassis',
+        'virtualdevicecontext',
+        'virtualdisk',
+        'virtualmachine',
+        'vlan',
+        'vlangroup',
+        'vminterface',
+        'vrf',
+        'webhook',
+        'wirelesslan',
+        'wirelesslangroup',
+        'wirelesslink',
+    )
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1193,6 +1283,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
 class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectChange.objects.all()
     queryset = ObjectChange.objects.all()
     filterset = ObjectChangeFilterSet
     filterset = ObjectChangeFilterSet
+    ignore_fields = ('prechange_data', 'postchange_data')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 74 - 22
netbox/ipam/filtersets.py

@@ -8,6 +8,7 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
+from circuits.models import Provider
 from dcim.models import Device, Interface, Region, Site, SiteGroup
 from dcim.models import Device, Interface, Region, Site, SiteGroup
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
@@ -75,7 +76,7 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
+        fields = ('id', 'name', 'rd', 'enforce_unique', 'description')
 
 
 
 
 class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -101,6 +102,28 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='rd',
         to_field_name='rd',
         label=_('Export VRF (RD)'),
         label=_('Export VRF (RD)'),
     )
     )
+    importing_l2vpn_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='importing_l2vpns',
+        queryset=L2VPN.objects.all(),
+        label=_('Importing L2VPN'),
+    )
+    importing_l2vpn = django_filters.ModelMultipleChoiceFilter(
+        field_name='importing_l2vpns__identifier',
+        queryset=L2VPN.objects.all(),
+        to_field_name='identifier',
+        label=_('Importing L2VPN (identifier)'),
+    )
+    exporting_l2vpn_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='exporting_l2vpns',
+        queryset=L2VPN.objects.all(),
+        label=_('Exporting L2VPN'),
+    )
+    exporting_l2vpn = django_filters.ModelMultipleChoiceFilter(
+        field_name='exporting_l2vpns__identifier',
+        queryset=L2VPN.objects.all(),
+        to_field_name='identifier',
+        label=_('Exporting L2VPN (identifier)'),
+    )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -112,14 +135,14 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = RouteTarget
         model = RouteTarget
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
 
 
 
 class RIRFilterSet(OrganizationalModelFilterSet):
 class RIRFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
-        fields = ['id', 'name', 'slug', 'is_private', 'description']
+        fields = ('id', 'name', 'slug', 'is_private', 'description')
 
 
 
 
 class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -144,7 +167,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
-        fields = ['id', 'date_added', 'description']
+        fields = ('id', 'date_added', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -183,7 +206,7 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = ASNRange
         model = ASNRange
-        fields = ['id', 'name', 'start', 'end', 'description']
+        fields = ('id', 'name', 'slug', 'start', 'end', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -214,10 +237,21 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
     )
     )
+    provider_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='providers',
+        queryset=Provider.objects.all(),
+        label=_('Provider (ID)'),
+    )
+    provider = django_filters.ModelMultipleChoiceFilter(
+        field_name='providers__slug',
+        queryset=Provider.objects.all(),
+        to_field_name='slug',
+        label=_('Provider (slug)'),
+    )
 
 
     class Meta:
     class Meta:
         model = ASN
         model = ASN
-        fields = ['id', 'asn', 'description']
+        fields = ('id', 'asn', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -234,7 +268,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description', 'weight')
 
 
 
 
 class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -359,7 +393,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['id', 'is_pool', 'mark_utilized', 'description']
+        fields = ('id', 'is_pool', 'mark_utilized', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -475,7 +509,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = IPRange
         model = IPRange
-        fields = ['id', 'mark_utilized', 'description']
+        fields = ('id', 'mark_utilized', 'size', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -628,10 +662,20 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     role = django_filters.MultipleChoiceFilter(
     role = django_filters.MultipleChoiceFilter(
         choices=IPAddressRoleChoices
         choices=IPAddressRoleChoices
     )
     )
+    service_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='services',
+        queryset=Service.objects.all(),
+        label=_('Service (ID)'),
+    )
+    nat_inside_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='nat_inside',
+        queryset=IPAddress.objects.all(),
+        label=_('NAT inside IP address (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['id', 'dns_name', 'description']
+        fields = ('id', 'dns_name', 'description', 'assigned_object_type', 'assigned_object_id')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -758,7 +802,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = FHRPGroup
         model = FHRPGroup
-        fields = ['id', 'group_id', 'name', 'auth_key', 'description']
+        fields = ('id', 'group_id', 'name', 'auth_key', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -819,7 +863,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = FHRPGroupAssignment
         model = FHRPGroupAssignment
-        fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority']
+        fields = ('id', 'group_id', 'interface_type', 'interface_id', 'priority')
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
         devices = Device.objects.filter(**{f'{name}__in': value})
         devices = Device.objects.filter(**{f'{name}__in': value})
@@ -849,7 +893,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
     region = django_filters.NumberFilter(
     region = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    sitegroup = django_filters.NumberFilter(
+    site_group = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
     site = django_filters.NumberFilter(
     site = django_filters.NumberFilter(
@@ -861,16 +905,20 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
     rack = django_filters.NumberFilter(
     rack = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
-    clustergroup = django_filters.NumberFilter(
+    cluster_group = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
     cluster = django_filters.NumberFilter(
     cluster = django_filters.NumberFilter(
         method='filter_scope'
         method='filter_scope'
     )
     )
 
 
+    # TODO: Remove in v4.1
+    sitegroup = site_group
+    clustergroup = cluster_group
+
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id']
+        fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -882,8 +930,9 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
     def filter_scope(self, queryset, name, value):
     def filter_scope(self, queryset, name, value):
+        model_name = name.replace('_', '')
         return queryset.filter(
         return queryset.filter(
-            scope_type=ContentType.objects.get(model=name),
+            scope_type=ContentType.objects.get(model=model_name),
             scope_id=value
             scope_id=value
         )
         )
 
 
@@ -975,7 +1024,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['id', 'vid', 'name', 'description']
+        fields = ('id', 'vid', 'name', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1008,7 +1057,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ServiceTemplate
         model = ServiceTemplate
-        fields = ['id', 'name', 'protocol', 'description']
+        fields = ('id', 'name', 'protocol', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1041,26 +1090,29 @@ class ServiceFilterSet(NetBoxModelFilterSet):
         to_field_name='name',
         to_field_name='name',
         label=_('Virtual machine (name)'),
         label=_('Virtual machine (name)'),
     )
     )
-    ipaddress_id = django_filters.ModelMultipleChoiceFilter(
+    ip_address_id = django_filters.ModelMultipleChoiceFilter(
         field_name='ipaddresses',
         field_name='ipaddresses',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         label=_('IP address (ID)'),
         label=_('IP address (ID)'),
     )
     )
-    ipaddress = django_filters.ModelMultipleChoiceFilter(
+    ip_address = django_filters.ModelMultipleChoiceFilter(
         field_name='ipaddresses__address',
         field_name='ipaddresses__address',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         to_field_name='address',
         to_field_name='address',
         label=_('IP address'),
         label=_('IP address'),
     )
     )
-
     port = NumericArrayFilter(
     port = NumericArrayFilter(
         field_name='ports',
         field_name='ports',
         lookup_expr='contains'
         lookup_expr='contains'
     )
     )
 
 
+    # TODO: Remove in v4.1
+    ipaddress = ip_address
+    ipaddress_id = ip_address_id
+
     class Meta:
     class Meta:
         model = Service
         model = Service
-        fields = ['id', 'name', 'protocol', 'description']
+        fields = ('id', 'name', 'protocol', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 1 - 1
netbox/ipam/forms/filtersets.py

@@ -304,7 +304,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
                 'placeholder': 'Prefix',
                 'placeholder': 'Prefix',
             }
             }
         ),
         ),
-        label='Parent Prefix'
+        label=_('Parent Prefix')
     )
     )
     family = forms.ChoiceField(
     family = forms.ChoiceField(
         required=False,
         required=False,

+ 91 - 10
netbox/ipam/tests/test_filtersets.py

@@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
+from circuits.models import Provider
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
@@ -10,6 +11,8 @@ from ipam.models import *
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from vpn.choices import L2VPNTypeChoices
+from vpn.models import L2VPN
 
 
 
 
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -110,13 +113,6 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         ]
         ]
         RIR.objects.bulk_create(rirs)
         RIR.objects.bulk_create(rirs)
 
 
-        sites = [
-            Site(name='Site 1', slug='site-1'),
-            Site(name='Site 2', slug='site-2'),
-            Site(name='Site 3', slug='site-3')
-        ]
-        Site.objects.bulk_create(sites)
-
         tenants = [
         tenants = [
             Tenant(name='Tenant 1', slug='tenant-1'),
             Tenant(name='Tenant 1', slug='tenant-1'),
             Tenant(name='Tenant 2', slug='tenant-2'),
             Tenant(name='Tenant 2', slug='tenant-2'),
@@ -136,6 +132,12 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         ASN.objects.bulk_create(asns)
         ASN.objects.bulk_create(asns)
 
 
+        sites = [
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3')
+        ]
+        Site.objects.bulk_create(sites)
         asns[0].sites.set([sites[0]])
         asns[0].sites.set([sites[0]])
         asns[1].sites.set([sites[1]])
         asns[1].sites.set([sites[1]])
         asns[2].sites.set([sites[2]])
         asns[2].sites.set([sites[2]])
@@ -143,6 +145,16 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         asns[4].sites.set([sites[1]])
         asns[4].sites.set([sites[1]])
         asns[5].sites.set([sites[2]])
         asns[5].sites.set([sites[2]])
 
 
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+        providers[0].asns.add(asns[0])
+        providers[1].asns.add(asns[1])
+        providers[2].asns.add(asns[2])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -176,11 +188,24 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         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)
 
 
+    def test_provider(self):
+        providers = Provider.objects.all()[:2]
+        params = {'provider_id': [providers[0].pk, providers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VRF.objects.all()
     queryset = VRF.objects.all()
     filterset = VRFFilterSet
     filterset = VRFFilterSet
 
 
+    def get_m2m_filter_name(self, field):
+        # Override filter names for import & export RouteTargets
+        if field.name == 'import_targets':
+            return 'import_target'
+        if field.name == 'export_targets':
+            return 'export_target'
+        return ChangeLoggedFilterSetTests.get_m2m_filter_name(field)
+
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -277,6 +302,18 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RouteTarget.objects.all()
     queryset = RouteTarget.objects.all()
     filterset = RouteTargetFilterSet
     filterset = RouteTargetFilterSet
 
 
+    def get_m2m_filter_name(self, field):
+        # Override filter names for import & export VRFs and L2VPNs
+        if field.name == 'importing_vrfs':
+            return 'importing_vrf'
+        if field.name == 'exporting_vrfs':
+            return 'exporting_vrf'
+        if field.name == 'importing_l2vpns':
+            return 'importing_l2vpn'
+        if field.name == 'exporting_l2vpns':
+            return 'exporting_l2vpn'
+        return ChangeLoggedFilterSetTests.get_m2m_filter_name(field)
+
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -322,6 +359,17 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
         vrfs[1].import_targets.add(route_targets[4], route_targets[5])
         vrfs[1].import_targets.add(route_targets[4], route_targets[5])
         vrfs[1].export_targets.add(route_targets[6], route_targets[7])
         vrfs[1].export_targets.add(route_targets[6], route_targets[7])
 
 
+        l2vpns = (
+            L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=100),
+            L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=200),
+            L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=300),
+        )
+        L2VPN.objects.bulk_create(l2vpns)
+        l2vpns[0].import_targets.add(route_targets[0], route_targets[1])
+        l2vpns[0].export_targets.add(route_targets[2], route_targets[3])
+        l2vpns[1].import_targets.add(route_targets[4], route_targets[5])
+        l2vpns[1].export_targets.add(route_targets[6], route_targets[7])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -344,6 +392,20 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'exporting_vrf': [vrfs[0].rd, vrfs[1].rd]}
         params = {'exporting_vrf': [vrfs[0].rd, vrfs[1].rd]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_importing_l2vpn(self):
+        l2vpns = L2VPN.objects.all()[:2]
+        params = {'importing_l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'importing_l2vpn': [l2vpns[0].identifier, l2vpns[1].identifier]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_exporting_l2vpn(self):
+        l2vpns = L2VPN.objects.all()[:2]
+        params = {'exporting_l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'exporting_l2vpn': [l2vpns[0].identifier, l2vpns[1].identifier]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_tenant(self):
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
@@ -922,6 +984,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
     queryset = IPAddress.objects.all()
     filterset = IPAddressFilterSet
     filterset = IPAddressFilterSet
+    ignore_fields = ('fhrpgroup',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1092,6 +1155,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         IPAddress.objects.bulk_create(ipaddresses)
         IPAddress.objects.bulk_create(ipaddresses)
 
 
+        services = (
+            Service(name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
+            Service(name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
+            Service(name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
+        )
+        Service.objects.bulk_create(services)
+        services[0].ipaddresses.add(ipaddresses[0])
+        services[1].ipaddresses.add(ipaddresses[1])
+        services[2].ipaddresses.add(ipaddresses[2])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -1231,6 +1304,11 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
+    def test_service(self):
+        services = Service.objects.all()[:2]
+        params = {'service_id': [services[0].pk, services[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
 class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = FHRPGroup.objects.all()
     queryset = FHRPGroup.objects.all()
@@ -1475,6 +1553,7 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLAN.objects.all()
     queryset = VLAN.objects.all()
     filterset = VLANFilterSet
     filterset = VLANFilterSet
+    ignore_fields = ('interfaces_as_tagged', 'vminterfaces_as_tagged')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1733,6 +1812,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ServiceTemplate.objects.all()
     queryset = ServiceTemplate.objects.all()
     filterset = ServiceTemplateFilterSet
     filterset = ServiceTemplateFilterSet
+    ignore_fields = ('ports',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1797,6 +1877,7 @@ class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Service.objects.all()
     queryset = Service.objects.all()
     filterset = ServiceFilterSet
     filterset = ServiceFilterSet
+    ignore_fields = ('ports',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1883,9 +1964,9 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_ipaddress(self):
+    def test_ip_address(self):
         ips = IPAddress.objects.all()[:2]
         ips = IPAddress.objects.all()[:2]
-        params = {'ipaddress_id': [ips[0].pk, ips[1].pk]}
+        params = {'ip_address_id': [ips[0].pk, ips[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]}
+        params = {'ip_address': [str(ips[0].address), str(ips[1].address)]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 6 - 6
netbox/tenancy/filtersets.py

@@ -50,14 +50,14 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ContactGroup
         model = ContactGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class ContactRoleFilterSet(OrganizationalModelFilterSet):
 class ContactRoleFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ContactRole
         model = ContactRole
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class ContactFilterSet(NetBoxModelFilterSet):
 class ContactFilterSet(NetBoxModelFilterSet):
@@ -77,7 +77,7 @@ class ContactFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Contact
         model = Contact
-        fields = ['id', 'name', 'title', 'phone', 'email', 'address', 'link', 'description']
+        fields = ('id', 'name', 'title', 'phone', 'email', 'address', 'link', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -131,7 +131,7 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ContactAssignment
         model = ContactAssignment
-        fields = ['id', 'object_type_id', 'object_id', 'priority', 'tag']
+        fields = ('id', 'object_type_id', 'object_id', 'priority', 'tag')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -192,7 +192,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@@ -212,7 +212,7 @@ class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 31 - 4
netbox/users/filtersets.py

@@ -3,8 +3,10 @@ from django.contrib.auth import get_user_model
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
+from core.models import ObjectType
 from netbox.filtersets import BaseFilterSet
 from netbox.filtersets import BaseFilterSet
 from users.models import Group, ObjectPermission, Token
 from users.models import Group, ObjectPermission, Token
+from utilities.filters import ContentTypeFilter
 
 
 __all__ = (
 __all__ = (
     'GroupFilterSet',
     'GroupFilterSet',
@@ -19,10 +21,20 @@ class GroupFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='user',
+        queryset=get_user_model().objects.all(),
+        label=_('User (ID)'),
+    )
+    permission_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='object_permissions',
+        queryset=ObjectPermission.objects.all(),
+        label=_('Permission (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = Group
         model = Group
-        fields = ['id', 'name']
+        fields = ('id', 'name', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -46,10 +58,18 @@ class UserFilterSet(BaseFilterSet):
         to_field_name='name',
         to_field_name='name',
         label=_('Group (name)'),
         label=_('Group (name)'),
     )
     )
+    permission_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='object_permissions',
+        queryset=ObjectPermission.objects.all(),
+        label=_('Permission (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = get_user_model()
         model = get_user_model()
-        fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'is_superuser']
+        fields = (
+            'id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login', 'is_staff', 'is_active',
+            'is_superuser',
+        )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -99,7 +119,7 @@ class TokenFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = Token
         model = Token
-        fields = ['id', 'key', 'write_enabled', 'description']
+        fields = ('id', 'key', 'write_enabled', 'description', 'last_used')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -115,6 +135,13 @@ class ObjectPermissionFilterSet(BaseFilterSet):
         method='search',
         method='search',
         label=_('Search'),
         label=_('Search'),
     )
     )
+    object_type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ObjectType.objects.all(),
+        field_name='object_types'
+    )
+    object_type = ContentTypeFilter(
+        field_name='object_types'
+    )
     can_view = django_filters.BooleanFilter(
     can_view = django_filters.BooleanFilter(
         method='_check_action'
         method='_check_action'
     )
     )
@@ -152,7 +179,7 @@ class ObjectPermissionFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = ObjectPermission
         model = ObjectPermission
-        fields = ['id', 'name', 'enabled', 'object_types', 'description']
+        fields = ('id', 'name', 'enabled', 'object_types', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 49 - 0
netbox/users/tests/test_filtersets.py

@@ -15,6 +15,7 @@ User = get_user_model()
 class UserTestCase(TestCase, BaseFilterSetTests):
 class UserTestCase(TestCase, BaseFilterSetTests):
     queryset = User.objects.all()
     queryset = User.objects.all()
     filterset = filtersets.UserFilterSet
     filterset = filtersets.UserFilterSet
+    ignore_fields = ('config', 'dashboard', 'password', 'user_permissions')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -66,6 +67,16 @@ class UserTestCase(TestCase, BaseFilterSetTests):
         users[1].groups.set([groups[1]])
         users[1].groups.set([groups[1]])
         users[2].groups.set([groups[2]])
         users[2].groups.set([groups[2]])
 
 
+        object_permissions = (
+            ObjectPermission(name='Permission 1', actions=['add']),
+            ObjectPermission(name='Permission 2', actions=['change']),
+            ObjectPermission(name='Permission 3', actions=['delete']),
+        )
+        ObjectPermission.objects.bulk_create(object_permissions)
+        object_permissions[0].users.add(users[0])
+        object_permissions[1].users.add(users[1])
+        object_permissions[2].users.add(users[2])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'user1'}
         params = {'q': 'user1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -105,10 +116,16 @@ class UserTestCase(TestCase, BaseFilterSetTests):
         params = {'group': [groups[0].name, groups[1].name]}
         params = {'group': [groups[0].name, groups[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_permission(self):
+        object_permissions = ObjectPermission.objects.all()[:2]
+        params = {'permission_id': [object_permissions[0].pk, object_permissions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class GroupTestCase(TestCase, BaseFilterSetTests):
 class GroupTestCase(TestCase, BaseFilterSetTests):
     queryset = Group.objects.all()
     queryset = Group.objects.all()
     filterset = filtersets.GroupFilterSet
     filterset = filtersets.GroupFilterSet
+    ignore_fields = ('permissions',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -120,6 +137,26 @@ class GroupTestCase(TestCase, BaseFilterSetTests):
         )
         )
         Group.objects.bulk_create(groups)
         Group.objects.bulk_create(groups)
 
 
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+        )
+        User.objects.bulk_create(users)
+        users[0].groups.set([groups[0]])
+        users[1].groups.set([groups[1]])
+        users[2].groups.set([groups[2]])
+
+        object_permissions = (
+            ObjectPermission(name='Permission 1', actions=['add']),
+            ObjectPermission(name='Permission 2', actions=['change']),
+            ObjectPermission(name='Permission 3', actions=['delete']),
+        )
+        ObjectPermission.objects.bulk_create(object_permissions)
+        object_permissions[0].groups.add(groups[0])
+        object_permissions[1].groups.add(groups[1])
+        object_permissions[2].groups.add(groups[2])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'group 1'}
         params = {'q': 'group 1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -128,10 +165,21 @@ class GroupTestCase(TestCase, BaseFilterSetTests):
         params = {'name': ['Group 1', 'Group 2']}
         params = {'name': ['Group 1', 'Group 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_user(self):
+        users = User.objects.all()[:2]
+        params = {'user_id': [users[0].pk, users[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_permission(self):
+        object_permissions = ObjectPermission.objects.all()[:2]
+        params = {'permission_id': [object_permissions[0].pk, object_permissions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
 class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectPermission.objects.all()
     queryset = ObjectPermission.objects.all()
     filterset = filtersets.ObjectPermissionFilterSet
     filterset = filtersets.ObjectPermissionFilterSet
+    ignore_fields = ('actions', 'constraints')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -226,6 +274,7 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
 class TokenTestCase(TestCase, BaseFilterSetTests):
 class TokenTestCase(TestCase, BaseFilterSetTests):
     queryset = Token.objects.all()
     queryset = Token.objects.all()
     filterset = filtersets.TokenFilterSet
     filterset = filtersets.TokenFilterSet
+    ignore_fields = ('allowed_ips',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 132 - 1
netbox/utilities/testing/filtersets.py

@@ -1,15 +1,91 @@
-from datetime import date, datetime, timezone
+import django_filters
+from datetime import datetime, timezone
+from itertools import chain
+from mptt.models import MPTTModel
 
 
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel
+from django.utils.module_loading import import_string
+from taggit.managers import TaggableManager
+
+from extras.filters import TagFilter
+from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
+
+from core.models import ObjectType
 
 
 __all__ = (
 __all__ = (
     'BaseFilterSetTests',
     'BaseFilterSetTests',
     'ChangeLoggedFilterSetTests',
     'ChangeLoggedFilterSetTests',
 )
 )
 
 
+EXEMPT_MODEL_FIELDS = (
+    'comments',
+    'custom_field_data',
+    'level',    # MPTT
+    'lft',      # MPTT
+    'rght',     # MPTT
+    'tree_id',  # MPTT
+)
+
 
 
 class BaseFilterSetTests:
 class BaseFilterSetTests:
     queryset = None
     queryset = None
     filterset = None
     filterset = None
+    ignore_fields = tuple()
+
+    def get_m2m_filter_name(self, field):
+        """
+        Given a ManyToManyField, determine the correct name for its corresponding Filter. Individual test
+        cases may override this method to prescribe deviations for specific fields.
+        """
+        related_model_name = field.related_model._meta.verbose_name
+        return related_model_name.lower().replace(' ', '_')
+
+    def get_filters_for_model_field(self, field):
+        """
+        Given a model field, return an iterable of (name, class) for each filter that should be defined on
+        the model's FilterSet class. If the appropriate filter class cannot be determined, it will be None.
+        """
+        # ForeignKey & OneToOneField
+        if issubclass(field.__class__, ForeignKey) or type(field) is OneToOneRel:
+
+            # Relationships to ContentType (used as part of a GFK) do not need a filter
+            if field.related_model is ContentType:
+                return [(None, None)]
+
+            # ForeignKeys to ObjectType need two filters: 'app.model' & PK
+            if field.related_model is ObjectType:
+                return [
+                    (field.name, ContentTypeFilter),
+                    (f'{field.name}_id', django_filters.ModelMultipleChoiceFilter),
+                ]
+
+            # ForeignKey to an MPTT-enabled model
+            if issubclass(field.related_model, MPTTModel) and field.model is not field.related_model:
+                return [(f'{field.name}_id', TreeNodeMultipleChoiceFilter)]
+
+            return [(f'{field.name}_id', django_filters.ModelMultipleChoiceFilter)]
+
+        # Many-to-many relationships (forward & backward)
+        elif type(field) in (ManyToManyField, ManyToManyRel):
+            filter_name = self.get_m2m_filter_name(field)
+
+            # ManyToManyFields to ObjectType need two filters: 'app.model' & PK
+            if field.related_model is ObjectType:
+                return [
+                    (filter_name, ContentTypeFilter),
+                    (f'{filter_name}_id', django_filters.ModelMultipleChoiceFilter),
+                ]
+
+            return [(f'{filter_name}_id', django_filters.ModelMultipleChoiceFilter)]
+
+        # Tag manager
+        if type(field) is TaggableManager:
+            return [('tag', TagFilter)]
+
+        # Unable to determine the correct filter class
+        return [(field.name, None)]
 
 
     def test_id(self):
     def test_id(self):
         """
         """
@@ -19,6 +95,61 @@ class BaseFilterSetTests:
         self.assertGreater(self.queryset.count(), 2)
         self.assertGreater(self.queryset.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_missing_filters(self):
+        """
+        Check for any model fields which do not have the required filter(s) defined.
+        """
+        app_label = self.__class__.__module__.split('.')[0]
+        model = self.queryset.model
+        model_name = model.__name__
+
+        # Import the FilterSet class & sanity check it
+        filterset = import_string(f'{app_label}.filtersets.{model_name}FilterSet')
+        self.assertEqual(model, filterset.Meta.model, "FilterSet model does not match!")
+
+        filters = filterset.get_filters()
+
+        # Check for missing filters
+        for model_field in model._meta.get_fields():
+
+            # Skip private fields
+            if model_field.name.startswith('_'):
+                continue
+
+            # Skip ignored fields
+            if model_field.name in chain(self.ignore_fields, EXEMPT_MODEL_FIELDS):
+                continue
+
+            # Skip reverse ForeignKey relationships
+            if type(model_field) is ManyToOneRel:
+                continue
+
+            # Skip generic relationships
+            if type(model_field) in (GenericForeignKey, GenericRelation):
+                continue
+
+            for filter_name, filter_class in self.get_filters_for_model_field(model_field):
+
+                if filter_name is None:
+                    # Field is exempt
+                    continue
+
+                # Check that the filter is defined
+                self.assertIn(
+                    filter_name,
+                    filters.keys(),
+                    f'No filter defined for {filter_name} ({model_field.name})!'
+                )
+
+                # Check that the filter class is correct
+                filter = filters[filter_name]
+                if filter_class is not None:
+                    self.assertIs(
+                        type(filter),
+                        filter_class,
+                        f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!"
+                    )
+
 
 
 class ChangeLoggedFilterSetTests(BaseFilterSetTests):
 class ChangeLoggedFilterSetTests(BaseFilterSetTests):
 
 

+ 6 - 6
netbox/virtualization/filtersets.py

@@ -27,14 +27,14 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
 class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
@@ -101,7 +101,7 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -240,7 +240,7 @@ class VirtualMachineFilterSet(
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
-        fields = ['id', 'cluster', 'vcpus', 'memory', 'disk', 'description']
+        fields = ('id', 'cluster', 'vcpus', 'memory', 'disk', 'description', 'interface_count', 'virtual_disk_count')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -299,7 +299,7 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
 
 
     class Meta:
     class Meta:
         model = VMInterface
         model = VMInterface
-        fields = ['id', 'name', 'enabled', 'mtu', 'description']
+        fields = ('id', 'name', 'enabled', 'mtu', 'mode', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -325,7 +325,7 @@ class VirtualDiskFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = VirtualDisk
         model = VirtualDisk
-        fields = ['id', 'name', 'size', 'description']
+        fields = ('id', 'name', 'size', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 1 - 0
netbox/virtualization/tests/test_filtersets.py

@@ -522,6 +522,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
     filterset = VMInterfaceFilterSet
     filterset = VMInterfaceFilterSet
+    ignore_fields = ('tagged_vlans', 'untagged_vlan',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):

+ 54 - 18
netbox/vpn/filtersets.py

@@ -29,7 +29,7 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = TunnelGroup
         model = TunnelGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -62,7 +62,7 @@ class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = Tunnel
         model = Tunnel
-        fields = ['id', 'name', 'tunnel_id', 'description']
+        fields = ('id', 'name', 'tunnel_id', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -120,10 +120,21 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = TunnelTermination
         model = TunnelTermination
-        fields = ['id']
+        fields = ('id', 'termination_id')
 
 
 
 
 class IKEProposalFilterSet(NetBoxModelFilterSet):
 class IKEProposalFilterSet(NetBoxModelFilterSet):
+    ike_policy_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='ike_policies',
+        queryset=IKEPolicy.objects.all(),
+        label=_('IKE policy (ID)'),
+    )
+    ike_policy = django_filters.ModelMultipleChoiceFilter(
+        field_name='ike_policies__name',
+        queryset=IKEPolicy.objects.all(),
+        to_field_name='name',
+        label=_('IKE policy (name)'),
+    )
     authentication_method = django_filters.MultipleChoiceFilter(
     authentication_method = django_filters.MultipleChoiceFilter(
         choices=AuthenticationMethodChoices
         choices=AuthenticationMethodChoices
     )
     )
@@ -139,7 +150,7 @@ class IKEProposalFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = IKEProposal
         model = IKEProposal
-        fields = ['id', 'name', 'sa_lifetime', 'description']
+        fields = ('id', 'name', 'sa_lifetime', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -158,16 +169,23 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet):
     mode = django_filters.MultipleChoiceFilter(
     mode = django_filters.MultipleChoiceFilter(
         choices=IKEModeChoices
         choices=IKEModeChoices
     )
     )
-    proposal_id = MultiValueNumberFilter(
-        field_name='proposals__id'
+    ike_proposal_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='proposals',
+        queryset=IKEProposal.objects.all()
     )
     )
-    proposal = MultiValueCharFilter(
-        field_name='proposals__name'
+    ike_proposal = django_filters.ModelMultipleChoiceFilter(
+        field_name='proposals__name',
+        queryset=IKEProposal.objects.all(),
+        to_field_name='name'
     )
     )
 
 
+    # TODO: Remove in v4.1
+    proposal = ike_proposal
+    proposal_id = ike_proposal_id
+
     class Meta:
     class Meta:
         model = IKEPolicy
         model = IKEPolicy
-        fields = ['id', 'name', 'preshared_key', 'description']
+        fields = ('id', 'name', 'preshared_key', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -180,6 +198,17 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet):
 
 
 
 
 class IPSecProposalFilterSet(NetBoxModelFilterSet):
 class IPSecProposalFilterSet(NetBoxModelFilterSet):
+    ipsec_policy_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='ipsec_policies',
+        queryset=IPSecPolicy.objects.all(),
+        label=_('IPSec policy (ID)'),
+    )
+    ipsec_policy = django_filters.ModelMultipleChoiceFilter(
+        field_name='ipsec_policies__name',
+        queryset=IPSecPolicy.objects.all(),
+        to_field_name='name',
+        label=_('IPSec policy (name)'),
+    )
     encryption_algorithm = django_filters.MultipleChoiceFilter(
     encryption_algorithm = django_filters.MultipleChoiceFilter(
         choices=EncryptionAlgorithmChoices
         choices=EncryptionAlgorithmChoices
     )
     )
@@ -189,7 +218,7 @@ class IPSecProposalFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = IPSecProposal
         model = IPSecProposal
-        fields = ['id', 'name', 'sa_lifetime_seconds', 'sa_lifetime_data', 'description']
+        fields = ('id', 'name', 'sa_lifetime_seconds', 'sa_lifetime_data', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -205,16 +234,23 @@ class IPSecPolicyFilterSet(NetBoxModelFilterSet):
     pfs_group = django_filters.MultipleChoiceFilter(
     pfs_group = django_filters.MultipleChoiceFilter(
         choices=DHGroupChoices
         choices=DHGroupChoices
     )
     )
-    proposal_id = MultiValueNumberFilter(
-        field_name='proposals__id'
+    ipsec_proposal_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='proposals',
+        queryset=IPSecProposal.objects.all()
     )
     )
-    proposal = MultiValueCharFilter(
-        field_name='proposals__name'
+    ipsec_proposal = django_filters.ModelMultipleChoiceFilter(
+        field_name='proposals__name',
+        queryset=IPSecProposal.objects.all(),
+        to_field_name='name'
     )
     )
 
 
+    # TODO: Remove in v4.1
+    proposal = ipsec_proposal
+    proposal_id = ipsec_proposal_id
+
     class Meta:
     class Meta:
         model = IPSecPolicy
         model = IPSecPolicy
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -253,7 +289,7 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = IPSecProfile
         model = IPSecProfile
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -295,7 +331,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', 'type', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -402,7 +438,7 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = L2VPNTermination
         model = L2VPNTermination
-        fields = ('id', 'assigned_object_type_id')
+        fields = ('id', 'assigned_object_id')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 50 - 9
netbox/vpn/tests/test_filtersets.py

@@ -1,4 +1,3 @@
-from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 
 
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
@@ -331,6 +330,16 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         IKEProposal.objects.bulk_create(ike_proposals)
         IKEProposal.objects.bulk_create(ike_proposals)
 
 
+        ike_policies = (
+            IKEPolicy(name='IKE Policy 1'),
+            IKEPolicy(name='IKE Policy 2'),
+            IKEPolicy(name='IKE Policy 3'),
+        )
+        IKEPolicy.objects.bulk_create(ike_policies)
+        ike_policies[0].proposals.add(ike_proposals[0])
+        ike_policies[1].proposals.add(ike_proposals[1])
+        ike_policies[2].proposals.add(ike_proposals[2])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -343,6 +352,13 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         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)
 
 
+    def test_ike_policy(self):
+        ike_policies = IKEPolicy.objects.all()[:2]
+        params = {'ike_policy_id': [ike_policies[0].pk, ike_policies[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ike_policy': [ike_policies[0].name, ike_policies[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_authentication_method(self):
     def test_authentication_method(self):
         params = {'authentication_method': [
         params = {'authentication_method': [
             AuthenticationMethodChoices.PRESHARED_KEYS, AuthenticationMethodChoices.CERTIFICATES
             AuthenticationMethodChoices.PRESHARED_KEYS, AuthenticationMethodChoices.CERTIFICATES
@@ -446,11 +462,11 @@ class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'mode': [IKEModeChoices.MAIN]}
         params = {'mode': [IKEModeChoices.MAIN]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_proposal(self):
+    def test_ike_proposal(self):
         proposals = IKEProposal.objects.all()[:2]
         proposals = IKEProposal.objects.all()[:2]
-        params = {'proposal_id': [proposals[0].pk, proposals[1].pk]}
+        params = {'ike_proposal_id': [proposals[0].pk, proposals[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'proposal': [proposals[0].name, proposals[1].name]}
+        params = {'ike_proposal': [proposals[0].name, proposals[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
@@ -488,6 +504,16 @@ class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         IPSecProposal.objects.bulk_create(ipsec_proposals)
         IPSecProposal.objects.bulk_create(ipsec_proposals)
 
 
+        ipsec_policies = (
+            IPSecPolicy(name='IPSec Policy 1'),
+            IPSecPolicy(name='IPSec Policy 2'),
+            IPSecPolicy(name='IPSec Policy 3'),
+        )
+        IPSecPolicy.objects.bulk_create(ipsec_policies)
+        ipsec_policies[0].proposals.add(ipsec_proposals[0])
+        ipsec_policies[1].proposals.add(ipsec_proposals[1])
+        ipsec_policies[2].proposals.add(ipsec_proposals[2])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -500,6 +526,13 @@ class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         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)
 
 
+    def test_ipsec_policy(self):
+        ipsec_policies = IPSecPolicy.objects.all()[:2]
+        params = {'ipsec_policy_id': [ipsec_policies[0].pk, ipsec_policies[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_encryption_algorithm(self):
     def test_encryption_algorithm(self):
         params = {'encryption_algorithm': [
         params = {'encryption_algorithm': [
             EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC
             EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC
@@ -584,11 +617,11 @@ class IPSecPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'pfs_group': [DHGroupChoices.GROUP_1, DHGroupChoices.GROUP_2]}
         params = {'pfs_group': [DHGroupChoices.GROUP_1, DHGroupChoices.GROUP_2]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_proposal(self):
+    def test_ipsec_proposal(self):
         proposals = IPSecProposal.objects.all()[:2]
         proposals = IPSecProposal.objects.all()[:2]
-        params = {'proposal_id': [proposals[0].pk, proposals[1].pk]}
+        params = {'ipsec_proposal_id': [proposals[0].pk, proposals[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'proposal': [proposals[0].name, proposals[1].name]}
+        params = {'ipsec_proposal': [proposals[0].name, proposals[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
@@ -710,6 +743,14 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = L2VPN.objects.all()
     queryset = L2VPN.objects.all()
     filterset = L2VPNFilterSet
     filterset = L2VPNFilterSet
 
 
+    def get_m2m_filter_name(self, field):
+        # Override filter names for import & export RouteTargets
+        if field.name == 'import_targets':
+            return 'import_target'
+        if field.name == 'export_targets':
+            return 'export_target'
+        return ChangeLoggedFilterSetTests.get_m2m_filter_name(field)
+
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -848,8 +889,8 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]}
         params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
-    def test_content_type(self):
-        params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk}
+    def test_termination_type(self):
+        params = {'assigned_object_type': 'ipam.vlan'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
     def test_interface(self):
     def test_interface(self):

+ 14 - 5
netbox/wireless/filtersets.py

@@ -2,6 +2,7 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from dcim.choices import LinkStatusChoices
 from dcim.choices import LinkStatusChoices
+from dcim.models import Interface
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
@@ -39,7 +40,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = WirelessLANGroup
         model = WirelessLANGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 
 
 class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -60,6 +61,10 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     vlan_id = django_filters.ModelMultipleChoiceFilter(
     vlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all()
         queryset=VLAN.objects.all()
     )
     )
+    interface_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Interface.objects.all(),
+        field_name='interfaces'
+    )
     auth_type = django_filters.MultipleChoiceFilter(
     auth_type = django_filters.MultipleChoiceFilter(
         choices=WirelessAuthTypeChoices
         choices=WirelessAuthTypeChoices
     )
     )
@@ -69,7 +74,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = WirelessLAN
         model = WirelessLAN
-        fields = ['id', 'ssid', 'auth_psk', 'description']
+        fields = ('id', 'ssid', 'auth_psk', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -82,8 +87,12 @@ class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
 
 
 class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
-    interface_a_id = MultiValueNumberFilter()
-    interface_b_id = MultiValueNumberFilter()
+    interface_a_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Interface.objects.all()
+    )
+    interface_b_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Interface.objects.all()
+    )
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=LinkStatusChoices
         choices=LinkStatusChoices
     )
     )
@@ -96,7 +105,7 @@ class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = WirelessLink
         model = WirelessLink
-        fields = ['id', 'ssid', 'auth_psk', 'description']
+        fields = ('id', 'ssid', 'auth_psk', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 16 - 0
netbox/wireless/tests/test_filtersets.py

@@ -153,6 +153,17 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         WirelessLAN.objects.bulk_create(wireless_lans)
         WirelessLAN.objects.bulk_create(wireless_lans)
 
 
+        device = create_test_device('Device 1')
+        interfaces = (
+            Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_80211N),
+            Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_80211N),
+            Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_80211N),
+        )
+        Interface.objects.bulk_create(interfaces)
+        interfaces[0].wireless_lans.add(wireless_lans[0])
+        interfaces[1].wireless_lans.add(wireless_lans[1])
+        interfaces[2].wireless_lans.add(wireless_lans[2])
+
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -200,6 +211,11 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_interface(self):
+        interfaces = Interface.objects.all()[:2]
+        params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
 class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = WirelessLink.objects.all()
     queryset = WirelessLink.objects.all()