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

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

Closes #15237: Add tests for missing filters
Jeremy Stretch 1 год назад
Родитель
Сommit
2d4295e2ed

+ 23 - 6
netbox/circuits/filtersets.py

@@ -67,7 +67,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 
     class Meta:
         model = Provider
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -95,7 +95,7 @@ class ProviderAccountFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = ProviderAccount
-        fields = ['id', 'name', 'account', 'description']
+        fields = ('id', 'name', 'account', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -122,7 +122,7 @@ class ProviderNetworkFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = ProviderNetwork
-        fields = ['id', 'name', 'service_id', 'description']
+        fields = ('id', 'name', 'service_id', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -139,7 +139,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = CircuitType
-        fields = ['id', 'name', 'slug', 'color', 'description']
+        fields = ('id', 'name', 'slug', 'color', 'description')
 
 
 class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
@@ -158,6 +158,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         queryset=ProviderAccount.objects.all(),
         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(
         field_name='terminations__provider_network',
         queryset=ProviderNetwork.objects.all(),
@@ -214,10 +220,18 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         to_field_name='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:
         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):
         if not value.strip():
@@ -258,7 +272,10 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
 
     class Meta:
         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):
         if not value.strip():

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

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

+ 3 - 5
netbox/core/filtersets.py

@@ -28,7 +28,7 @@ class DataSourceFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = DataSource
-        fields = ('id', 'name', 'enabled', 'description')
+        fields = ('id', 'name', 'enabled', 'description', 'source_url', 'last_synced')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -115,7 +115,7 @@ class JobFilterSet(BaseFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -134,9 +134,7 @@ class ConfigRevisionFilterSet(BaseFilterSet):
 
     class Meta:
         model = ConfigRevision
-        fields = [
-            'id',
-        ]
+        fields = ('id', 'created', 'comment')
 
     def search(self, queryset, name, value):
         if not value.strip():

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

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

+ 159 - 64
netbox/dcim/filtersets.py

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

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

@@ -754,7 +754,7 @@ class DeviceFilterForm(
     )
     has_oob_ip = forms.NullBooleanField(
         required=False,
-        label='Has an OOB IP',
+        label=_('Has an OOB IP'),
         widget=forms.Select(
             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):
     queryset = Site.objects.all()
     filterset = SiteFilterSet
+    ignore_fields = ('physical_address', 'shipping_address')
 
     @classmethod
     def setUpTestData(cls):
@@ -467,6 +468,7 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Rack.objects.all()
     filterset = RackFilterSet
+    ignore_fields = ('units',)
 
     @classmethod
     def setUpTestData(cls):
@@ -726,6 +728,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
 class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RackReservation.objects.all()
     filterset = RackReservationFilterSet
+    ignore_fields = ('units',)
 
     @classmethod
     def setUpTestData(cls):
@@ -889,6 +892,7 @@ class ManufacturerTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceType.objects.all()
     filterset = DeviceTypeFilterSet
+    ignore_fields = ('front_image', 'rear_image')
 
     @classmethod
     def setUpTestData(cls):
@@ -1880,6 +1884,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Device.objects.all()
     filterset = DeviceFilterSet
+    ignore_fields = ('local_context_data', 'oob_ip', 'primary_ip4', 'primary_ip6', 'vc_master_for')
 
     @classmethod
     def setUpTestData(cls):
@@ -2332,6 +2337,7 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Module.objects.all()
     filterset = ModuleFilterSet
+    ignore_fields = ('local_context_data',)
 
     @classmethod
     def setUpTestData(cls):
@@ -3229,6 +3235,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
+    ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs')
 
     @classmethod
     def setUpTestData(cls):
@@ -5332,6 +5339,7 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualDeviceContext.objects.all()
     filterset = VirtualDeviceContextFilterSet
+    ignore_fields = ('primary_ip4', 'primary_ip6')
 
     @classmethod
     def setUpTestData(cls):
@@ -5401,15 +5409,22 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         VirtualDeviceContext.objects.bulk_create(vdcs)
 
         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)
-
         interfaces[0].vdcs.set([vdcs[0]])
         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[1], address='10.1.1.2/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=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[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()
 
     def test_q(self):
@@ -5431,8 +5445,11 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     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)
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_status(self):
         params = {'status': ['active']}
@@ -5442,10 +5459,10 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         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):
         params = {'has_primary_ip': True}

+ 65 - 50
netbox/extras/filtersets.py

@@ -40,12 +40,14 @@ class ScriptFilterSet(BaseFilterSet):
         method='search',
         label=_('Search'),
     )
+    module_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ScriptModule.objects.all(),
+        label=_('Script module (ID)'),
+    )
 
     class Meta:
         model = Script
-        fields = [
-            'id', 'name',
-        ]
+        fields = ('id', 'name', 'is_executable')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -69,10 +71,10 @@ class WebhookFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = Webhook
-        fields = [
+        fields = (
             'id', 'name', 'payload_url', 'http_method', 'http_content_type', 'secret', 'ssl_verification',
             'ca_file_path', 'description',
-        ]
+        )
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -89,8 +91,9 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
         method='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(
         field_name='object_types'
@@ -103,10 +106,10 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = EventRule
-        fields = [
+        fields = (
             'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled',
             'action_type', 'description',
-        ]
+        )
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -118,7 +121,7 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
         )
 
 
-class CustomFieldFilterSet(BaseFilterSet):
+class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
@@ -126,14 +129,16 @@ class CustomFieldFilterSet(BaseFilterSet):
     type = django_filters.MultipleChoiceFilter(
         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(
         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()
     choice_set_id = django_filters.ModelMultipleChoiceFilter(
@@ -147,10 +152,11 @@ class CustomFieldFilterSet(BaseFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -163,7 +169,7 @@ class CustomFieldFilterSet(BaseFilterSet):
         )
 
 
-class CustomFieldChoiceSetFilterSet(BaseFilterSet):
+class CustomFieldChoiceSetFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
@@ -174,9 +180,9 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
 
     class Meta:
         model = CustomFieldChoiceSet
-        fields = [
+        fields = (
             'id', 'name', 'description', 'base_choices', 'order_alphabetically',
-        ]
+        )
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -191,13 +197,14 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
         return queryset.filter(extra_choices__overlap=value)
 
 
-class CustomLinkFilterSet(BaseFilterSet):
+class CustomLinkFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='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(
         field_name='object_types'
@@ -205,9 +212,9 @@ class CustomLinkFilterSet(BaseFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -220,13 +227,14 @@ class CustomLinkFilterSet(BaseFilterSet):
         )
 
 
-class ExportTemplateFilterSet(BaseFilterSet):
+class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='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(
         field_name='object_types'
@@ -242,7 +250,10 @@ class ExportTemplateFilterSet(BaseFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -253,13 +264,14 @@ class ExportTemplateFilterSet(BaseFilterSet):
         )
 
 
-class SavedFilterFilterSet(BaseFilterSet):
+class SavedFilterFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='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(
         field_name='object_types'
@@ -280,7 +292,7 @@ class SavedFilterFilterSet(BaseFilterSet):
 
     class Meta:
         model = SavedFilter
-        fields = ['id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight']
+        fields = ('id', 'name', 'slug', 'description', 'enabled', 'shared', 'weight')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -321,20 +333,19 @@ class BookmarkFilterSet(BaseFilterSet):
 
     class Meta:
         model = Bookmark
-        fields = ['id', 'object_id']
+        fields = ('id', 'object_id')
 
 
-class ImageAttachmentFilterSet(BaseFilterSet):
+class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
     )
-    created = django_filters.DateTimeFilter()
     object_type = ContentTypeFilter()
 
     class Meta:
         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):
         if not value.strip():
@@ -364,7 +375,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -389,7 +400,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
 
     class Meta:
         model = Tag
-        fields = ['id', 'name', 'slug', 'color', 'description', 'object_types']
+        fields = ('id', 'name', 'slug', 'color', 'description', 'object_types')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -486,12 +497,12 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         queryset=DeviceType.objects.all(),
         label=_('Device type'),
     )
-    role_id = django_filters.ModelMultipleChoiceFilter(
+    device_role_id = django_filters.ModelMultipleChoiceFilter(
         field_name='roles',
         queryset=DeviceRole.objects.all(),
         label=_('Role'),
     )
-    role = django_filters.ModelMultipleChoiceFilter(
+    device_role = django_filters.ModelMultipleChoiceFilter(
         field_name='roles__slug',
         queryset=DeviceRole.objects.all(),
         to_field_name='slug',
@@ -577,9 +588,13 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         label=_('Data file (ID)'),
     )
 
+    # TODO: Remove in v4.1
+    role = device_role
+    role_id = device_role_id
+
     class Meta:
         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):
         if not value.strip():
@@ -591,7 +606,7 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         )
 
 
-class ConfigTemplateFilterSet(BaseFilterSet):
+class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
     q = django_filters.CharFilter(
         method='search',
         label=_('Search'),
@@ -608,7 +623,7 @@ class ConfigTemplateFilterSet(BaseFilterSet):
 
     class Meta:
         model = ConfigTemplate
-        fields = ['id', 'name', 'description', 'data_synced']
+        fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -656,10 +671,10 @@ class ObjectChangeFilterSet(BaseFilterSet):
 
     class Meta:
         model = ObjectChange
-        fields = [
+        fields = (
             '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):
         if not value.strip():
@@ -682,7 +697,7 @@ class ObjectTypeFilterSet(django_filters.FilterSet):
 
     class Meta:
         model = ObjectType
-        fields = ['id', 'app_label', 'model']
+        fields = ('id', 'app_label', 'model')
 
     def search(self, queryset, name, value):
         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()
 
 
-class CustomFieldTestCase(TestCase, BaseFilterSetTests):
+class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomField.objects.all()
     filterset = CustomFieldFilterSet
+    ignore_fields = ('default',)
 
     @classmethod
     def setUpTestData(cls):
@@ -155,9 +156,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
+class CustomFieldChoiceSetTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomFieldChoiceSet.objects.all()
     filterset = CustomFieldChoiceSetFilterSet
+    ignore_fields = ('extra_choices',)
 
     @classmethod
     def setUpTestData(cls):
@@ -188,6 +190,7 @@ class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
 class WebhookTestCase(TestCase, BaseFilterSetTests):
     queryset = Webhook.objects.all()
     filterset = WebhookFilterSet
+    ignore_fields = ('additional_headers', 'body_template')
 
     @classmethod
     def setUpTestData(cls):
@@ -252,6 +255,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
 class EventRuleTestCase(TestCase, BaseFilterSetTests):
     queryset = EventRule.objects.all()
     filterset = EventRuleFilterSet
+    ignore_fields = ('action_data', 'conditions')
 
     @classmethod
     def setUpTestData(cls):
@@ -405,7 +409,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class CustomLinkTestCase(TestCase, BaseFilterSetTests):
+class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = CustomLink.objects.all()
     filterset = CustomLinkFilterSet
 
@@ -474,9 +478,10 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
-class SavedFilterTestCase(TestCase, BaseFilterSetTests):
+class SavedFilterTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = SavedFilter.objects.all()
     filterset = SavedFilterFilterSet
+    ignore_fields = ('parameters',)
 
     @classmethod
     def setUpTestData(cls):
@@ -647,9 +652,10 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
+class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
+    ignore_fields = ('template_code', 'data_path')
 
     @classmethod
     def setUpTestData(cls):
@@ -683,9 +689,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
+class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ImageAttachment.objects.all()
     filterset = ImageAttachmentFilterSet
+    ignore_fields = ('image',)
 
     @classmethod
     def setUpTestData(cls):
@@ -760,12 +767,6 @@ class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
         }
         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):
     queryset = JournalEntry.objects.all()
@@ -873,6 +874,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigContext.objects.all()
     filterset = ConfigContextFilterSet
+    ignore_fields = ('data', 'data_path')
 
     @classmethod
     def setUpTestData(cls):
@@ -1041,11 +1043,11 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
         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]
-        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)
-        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)
 
     def test_platform(self):
@@ -1096,9 +1098,10 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-class ConfigTemplateTestCase(TestCase, BaseFilterSetTests):
+class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConfigTemplate.objects.all()
     filterset = ConfigTemplateFilterSet
+    ignore_fields = ('template_code', 'environment_params', 'data_path')
 
     @classmethod
     def setUpTestData(cls):
@@ -1125,6 +1128,93 @@ class ConfigTemplateTestCase(TestCase, BaseFilterSetTests):
 class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tag.objects.all()
     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
     def setUpTestData(cls):
@@ -1193,6 +1283,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectChange.objects.all()
     filterset = ObjectChangeFilterSet
+    ignore_fields = ('prechange_data', 'postchange_data')
 
     @classmethod
     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 netaddr.core import AddrFormatError
 
+from circuits.models import Provider
 from dcim.models import Device, Interface, Region, Site, SiteGroup
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
@@ -75,7 +76,7 @@ class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = VRF
-        fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
+        fields = ('id', 'name', 'rd', 'enforce_unique', 'description')
 
 
 class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -101,6 +102,28 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='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):
         if not value.strip():
@@ -112,14 +135,14 @@ class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = RouteTarget
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
 
 class RIRFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = RIR
-        fields = ['id', 'name', 'slug', 'is_private', 'description']
+        fields = ('id', 'name', 'slug', 'is_private', 'description')
 
 
 class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -144,7 +167,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = Aggregate
-        fields = ['id', 'date_added', 'description']
+        fields = ('id', 'date_added', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -183,7 +206,7 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = ASNRange
-        fields = ['id', 'name', 'start', 'end', 'description']
+        fields = ('id', 'name', 'slug', 'start', 'end', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -214,10 +237,21 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
         to_field_name='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:
         model = ASN
-        fields = ['id', 'asn', 'description']
+        fields = ('id', 'asn', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -234,7 +268,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = Role
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description', 'weight')
 
 
 class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -359,7 +393,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = Prefix
-        fields = ['id', 'is_pool', 'mark_utilized', 'description']
+        fields = ('id', 'is_pool', 'mark_utilized', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -475,7 +509,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
 
     class Meta:
         model = IPRange
-        fields = ['id', 'mark_utilized', 'description']
+        fields = ('id', 'mark_utilized', 'size', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -628,10 +662,20 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     role = django_filters.MultipleChoiceFilter(
         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:
         model = IPAddress
-        fields = ['id', 'dns_name', 'description']
+        fields = ('id', 'dns_name', 'description', 'assigned_object_type', 'assigned_object_id')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -758,7 +802,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = FHRPGroup
-        fields = ['id', 'group_id', 'name', 'auth_key', 'description']
+        fields = ('id', 'group_id', 'name', 'auth_key', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -819,7 +863,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
 
     class Meta:
         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):
         devices = Device.objects.filter(**{f'{name}__in': value})
@@ -849,7 +893,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
     region = django_filters.NumberFilter(
         method='filter_scope'
     )
-    sitegroup = django_filters.NumberFilter(
+    site_group = django_filters.NumberFilter(
         method='filter_scope'
     )
     site = django_filters.NumberFilter(
@@ -861,16 +905,20 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
     rack = django_filters.NumberFilter(
         method='filter_scope'
     )
-    clustergroup = django_filters.NumberFilter(
+    cluster_group = django_filters.NumberFilter(
         method='filter_scope'
     )
     cluster = django_filters.NumberFilter(
         method='filter_scope'
     )
 
+    # TODO: Remove in v4.1
+    sitegroup = site_group
+    clustergroup = cluster_group
+
     class Meta:
         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):
         if not value.strip():
@@ -882,8 +930,9 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
         return queryset.filter(qs_filter)
 
     def filter_scope(self, queryset, name, value):
+        model_name = name.replace('_', '')
         return queryset.filter(
-            scope_type=ContentType.objects.get(model=name),
+            scope_type=ContentType.objects.get(model=model_name),
             scope_id=value
         )
 
@@ -975,7 +1024,7 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = VLAN
-        fields = ['id', 'vid', 'name', 'description']
+        fields = ('id', 'vid', 'name', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -1008,7 +1057,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = ServiceTemplate
-        fields = ['id', 'name', 'protocol', 'description']
+        fields = ('id', 'name', 'protocol', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -1041,26 +1090,29 @@ class ServiceFilterSet(NetBoxModelFilterSet):
         to_field_name='name',
         label=_('Virtual machine (name)'),
     )
-    ipaddress_id = django_filters.ModelMultipleChoiceFilter(
+    ip_address_id = django_filters.ModelMultipleChoiceFilter(
         field_name='ipaddresses',
         queryset=IPAddress.objects.all(),
         label=_('IP address (ID)'),
     )
-    ipaddress = django_filters.ModelMultipleChoiceFilter(
+    ip_address = django_filters.ModelMultipleChoiceFilter(
         field_name='ipaddresses__address',
         queryset=IPAddress.objects.all(),
         to_field_name='address',
         label=_('IP address'),
     )
-
     port = NumericArrayFilter(
         field_name='ports',
         lookup_expr='contains'
     )
 
+    # TODO: Remove in v4.1
+    ipaddress = ip_address
+    ipaddress_id = ip_address_id
+
     class Meta:
         model = Service
-        fields = ['id', 'name', 'protocol', 'description']
+        fields = ('id', 'name', 'protocol', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():

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

@@ -304,7 +304,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
                 'placeholder': 'Prefix',
             }
         ),
-        label='Parent Prefix'
+        label=_('Parent Prefix')
     )
     family = forms.ChoiceField(
         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 netaddr import IPNetwork
 
+from circuits.models import Provider
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from ipam.choices import *
@@ -10,6 +11,8 @@ from ipam.models import *
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from vpn.choices import L2VPNTypeChoices
+from vpn.models import L2VPN
 
 
 class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -110,13 +113,6 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         ]
         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 = [
             Tenant(name='Tenant 1', slug='tenant-1'),
             Tenant(name='Tenant 2', slug='tenant-2'),
@@ -136,6 +132,12 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         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[1].sites.set([sites[1]])
         asns[2].sites.set([sites[2]])
@@ -143,6 +145,16 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         asns[4].sites.set([sites[1]])
         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):
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -176,11 +188,24 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         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):
     queryset = VRF.objects.all()
     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
     def setUpTestData(cls):
 
@@ -277,6 +302,18 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = RouteTarget.objects.all()
     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
     def setUpTestData(cls):
 
@@ -322,6 +359,17 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
         vrfs[1].import_targets.add(route_targets[4], route_targets[5])
         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):
         params = {'q': 'foobar1'}
         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]}
         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):
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
@@ -922,6 +984,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
     filterset = IPAddressFilterSet
+    ignore_fields = ('fhrpgroup',)
 
     @classmethod
     def setUpTestData(cls):
@@ -1092,6 +1155,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         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):
         params = {'q': 'foobar1'}
         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]}
         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):
     queryset = FHRPGroup.objects.all()
@@ -1475,6 +1553,7 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLAN.objects.all()
     filterset = VLANFilterSet
+    ignore_fields = ('interfaces_as_tagged', 'vminterfaces_as_tagged')
 
     @classmethod
     def setUpTestData(cls):
@@ -1733,6 +1812,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ServiceTemplate.objects.all()
     filterset = ServiceTemplateFilterSet
+    ignore_fields = ('ports',)
 
     @classmethod
     def setUpTestData(cls):
@@ -1797,6 +1877,7 @@ class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Service.objects.all()
     filterset = ServiceFilterSet
+    ignore_fields = ('ports',)
 
     @classmethod
     def setUpTestData(cls):
@@ -1883,9 +1964,9 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'virtual_machine': [vms[0].name, vms[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_ipaddress(self):
+    def test_ip_address(self):
         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)
-        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)

+ 6 - 6
netbox/tenancy/filtersets.py

@@ -50,14 +50,14 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = ContactGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 class ContactRoleFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = ContactRole
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 class ContactFilterSet(NetBoxModelFilterSet):
@@ -77,7 +77,7 @@ class ContactFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -131,7 +131,7 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -192,7 +192,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = TenantGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
@@ -212,7 +212,7 @@ class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
 
     class Meta:
         model = Tenant
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
     def search(self, queryset, name, value):
         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.utils.translation import gettext as _
 
+from core.models import ObjectType
 from netbox.filtersets import BaseFilterSet
 from users.models import Group, ObjectPermission, Token
+from utilities.filters import ContentTypeFilter
 
 __all__ = (
     'GroupFilterSet',
@@ -19,10 +21,20 @@ class GroupFilterSet(BaseFilterSet):
         method='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:
         model = Group
-        fields = ['id', 'name']
+        fields = ('id', 'name', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -46,10 +58,18 @@ class UserFilterSet(BaseFilterSet):
         to_field_name='name',
         label=_('Group (name)'),
     )
+    permission_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='object_permissions',
+        queryset=ObjectPermission.objects.all(),
+        label=_('Permission (ID)'),
+    )
 
     class Meta:
         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):
         if not value.strip():
@@ -99,7 +119,7 @@ class TokenFilterSet(BaseFilterSet):
 
     class Meta:
         model = Token
-        fields = ['id', 'key', 'write_enabled', 'description']
+        fields = ('id', 'key', 'write_enabled', 'description', 'last_used')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -115,6 +135,13 @@ class ObjectPermissionFilterSet(BaseFilterSet):
         method='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(
         method='_check_action'
     )
@@ -152,7 +179,7 @@ class ObjectPermissionFilterSet(BaseFilterSet):
 
     class Meta:
         model = ObjectPermission
-        fields = ['id', 'name', 'enabled', 'object_types', 'description']
+        fields = ('id', 'name', 'enabled', 'object_types', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():

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

@@ -15,6 +15,7 @@ User = get_user_model()
 class UserTestCase(TestCase, BaseFilterSetTests):
     queryset = User.objects.all()
     filterset = filtersets.UserFilterSet
+    ignore_fields = ('config', 'dashboard', 'password', 'user_permissions')
 
     @classmethod
     def setUpTestData(cls):
@@ -66,6 +67,16 @@ class UserTestCase(TestCase, BaseFilterSetTests):
         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].users.add(users[0])
+        object_permissions[1].users.add(users[1])
+        object_permissions[2].users.add(users[2])
+
     def test_q(self):
         params = {'q': 'user1'}
         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]}
         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):
     queryset = Group.objects.all()
     filterset = filtersets.GroupFilterSet
+    ignore_fields = ('permissions',)
 
     @classmethod
     def setUpTestData(cls):
@@ -120,6 +137,26 @@ class GroupTestCase(TestCase, BaseFilterSetTests):
         )
         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):
         params = {'q': 'group 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']}
         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):
     queryset = ObjectPermission.objects.all()
     filterset = filtersets.ObjectPermissionFilterSet
+    ignore_fields = ('actions', 'constraints')
 
     @classmethod
     def setUpTestData(cls):
@@ -226,6 +274,7 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
 class TokenTestCase(TestCase, BaseFilterSetTests):
     queryset = Token.objects.all()
     filterset = filtersets.TokenFilterSet
+    ignore_fields = ('allowed_ips',)
 
     @classmethod
     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__ = (
     'BaseFilterSetTests',
     'ChangeLoggedFilterSetTests',
 )
 
+EXEMPT_MODEL_FIELDS = (
+    'comments',
+    'custom_field_data',
+    'level',    # MPTT
+    'lft',      # MPTT
+    'rght',     # MPTT
+    'tree_id',  # MPTT
+)
+
 
 class BaseFilterSetTests:
     queryset = 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):
         """
@@ -19,6 +95,61 @@ class BaseFilterSetTests:
         self.assertGreater(self.queryset.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):
 

+ 6 - 6
netbox/virtualization/filtersets.py

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

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

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

+ 54 - 18
netbox/vpn/filtersets.py

@@ -29,7 +29,7 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet):
 
     class Meta:
         model = TunnelGroup
-        fields = ['id', 'name', 'slug', 'description']
+        fields = ('id', 'name', 'slug', 'description')
 
 
 class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
@@ -62,7 +62,7 @@ class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = Tunnel
-        fields = ['id', 'name', 'tunnel_id', 'description']
+        fields = ('id', 'name', 'tunnel_id', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -120,10 +120,21 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = TunnelTermination
-        fields = ['id']
+        fields = ('id', 'termination_id')
 
 
 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(
         choices=AuthenticationMethodChoices
     )
@@ -139,7 +150,7 @@ class IKEProposalFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = IKEProposal
-        fields = ['id', 'name', 'sa_lifetime', 'description']
+        fields = ('id', 'name', 'sa_lifetime', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -158,16 +169,23 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet):
     mode = django_filters.MultipleChoiceFilter(
         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:
         model = IKEPolicy
-        fields = ['id', 'name', 'preshared_key', 'description']
+        fields = ('id', 'name', 'preshared_key', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -180,6 +198,17 @@ class IKEPolicyFilterSet(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(
         choices=EncryptionAlgorithmChoices
     )
@@ -189,7 +218,7 @@ class IPSecProposalFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         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):
         if not value.strip():
@@ -205,16 +234,23 @@ class IPSecPolicyFilterSet(NetBoxModelFilterSet):
     pfs_group = django_filters.MultipleChoiceFilter(
         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:
         model = IPSecPolicy
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -253,7 +289,7 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = IPSecProfile
-        fields = ['id', 'name', 'description']
+        fields = ('id', 'name', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -295,7 +331,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = L2VPN
-        fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
+        fields = ('id', 'identifier', 'name', 'slug', 'type', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():
@@ -402,7 +438,7 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
 
     class Meta:
         model = L2VPNTermination
-        fields = ('id', 'assigned_object_type_id')
+        fields = ('id', 'assigned_object_id')
 
     def search(self, queryset, name, value):
         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 dcim.choices import InterfaceTypeChoices
@@ -331,6 +330,16 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         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):
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -343,6 +352,13 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         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):
         params = {'authentication_method': [
             AuthenticationMethodChoices.PRESHARED_KEYS, AuthenticationMethodChoices.CERTIFICATES
@@ -446,11 +462,11 @@ class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'mode': [IKEModeChoices.MAIN]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_proposal(self):
+    def test_ike_proposal(self):
         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)
-        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)
 
 
@@ -488,6 +504,16 @@ class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         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):
         params = {'q': 'foobar1'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -500,6 +526,13 @@ class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         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):
         params = {'encryption_algorithm': [
             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]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_proposal(self):
+    def test_ipsec_proposal(self):
         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)
-        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)
 
 
@@ -710,6 +743,14 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = L2VPN.objects.all()
     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
     def setUpTestData(cls):
 
@@ -848,8 +889,8 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]}
         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)
 
     def test_interface(self):

+ 14 - 5
netbox/wireless/filtersets.py

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