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

Merge pull request #7031 from netbox-community/object-filter-forms

Object filter forms
Jeremy Stretch 4 лет назад
Родитель
Сommit
6a4ed099fc

+ 26 - 14
netbox/circuits/forms.py

@@ -107,9 +107,9 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
 class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = Provider
     field_groups = [
-        ['q'],
-        ['region_id', 'site_id'],
-        ['asn', 'tag'],
+        ['q', 'tag'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['asn'],
     ]
     q = forms.CharField(
         required=False,
@@ -122,11 +122,18 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
         label=_('Region'),
         fetch_trigger='open'
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'site_group_id': '$site_group_id',
         },
         label=_('Site'),
         fetch_trigger='open'
@@ -202,7 +209,10 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
 
 class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = ProviderNetwork
-    field_order = ['q', 'provider_id', 'tag']
+    field_groups = (
+        ('q', 'tag'),
+        ('provider_id',),
+    )
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
@@ -368,17 +378,12 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
 
 class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = Circuit
-    field_order = [
-        'q', 'type_id', 'provider_id', 'provider_network_id', 'status', 'region_id', 'site_id', 'tenant_group_id',
-        'tenant_id', 'commit_rate',
-    ]
     field_groups = [
-        ['q'],
-        ['type_id', 'status', 'commit_rate'],
+        ['q', 'tag'],
         ['provider_id', 'provider_network_id'],
-        ['region_id', 'site_id'],
+        ['type_id', 'status', 'commit_rate'],
+        ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['tag']
     ]
     q = forms.CharField(
         required=False,
@@ -417,11 +422,18 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
         label=_('Region'),
         fetch_trigger='open'
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'site_group_id': '$site_group_id',
         },
         label=_('Site'),
         fetch_trigger='open'

+ 11 - 33
netbox/dcim/filtersets.py

@@ -831,6 +831,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         to_field_name='slug',
         label='Site name (slug)',
     )
+    location_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__location',
+        queryset=Location.objects.all(),
+        label='Location (ID)',
+    )
+    location = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__location__slug',
+        queryset=Location.objects.all(),
+        to_field_name='slug',
+        label='Location (slug)',
+    )
     device_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Device.objects.all(),
         label='Device (ID)',
@@ -1053,39 +1064,6 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
         method='search',
         label='Search',
     )
-    region_id = TreeNodeMultipleChoiceFilter(
-        queryset=Region.objects.all(),
-        field_name='device__site__region',
-        lookup_expr='in',
-        label='Region (ID)',
-    )
-    region = TreeNodeMultipleChoiceFilter(
-        queryset=Region.objects.all(),
-        field_name='device__site__region',
-        lookup_expr='in',
-        to_field_name='slug',
-        label='Region (slug)',
-    )
-    site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site',
-        queryset=Site.objects.all(),
-        label='Site (ID)',
-    )
-    site = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__site__slug',
-        queryset=Site.objects.all(),
-        to_field_name='slug',
-        label='Site name (slug)',
-    )
-    device_id = django_filters.ModelChoiceFilter(
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
-    device = django_filters.ModelChoiceFilter(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label='Device (name)',
-    )
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItem.objects.all(),
         label='Parent inventory item (ID)',

+ 94 - 91
netbox/dcim/forms.py

@@ -58,11 +58,6 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     field_order = [
         'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
     ]
-    field_groups = [
-        ['q'],
-        ['name', 'label'],
-        ['region_id', 'site_group_id', 'site_id'],
-    ]
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
@@ -90,16 +85,27 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         label=_('Site'),
         fetch_trigger='open'
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site_id',
+        },
+        label=_('Location'),
+        fetch_trigger='open'
+    )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         required=False,
         query_params={
-            'site_id': '$site_id'
+            'site_id': '$site_id',
+            'location_id': '$location_id',
         },
         label=_('Device'),
         fetch_trigger='open'
@@ -247,15 +253,22 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
 
 
 class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
-    model = Site
+    model = Region
     field_groups = [
         ['q'],
+        ['parent_id'],
     ]
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
         label=_('Search')
     )
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        label=_('Parent region'),
+        fetch_trigger='open'
+    )
 
 
 #
@@ -311,12 +324,19 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = SiteGroup
     field_groups = [
         ['q'],
+        ['parent_id'],
     ]
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
         label=_('Search')
     )
+    parent_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Parent group'),
+        fetch_trigger='open'
+    )
 
 
 #
@@ -476,10 +496,9 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
     model = Site
     field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id']
     field_groups = [
-        ['q'],
-        ['status', 'region_id'],
+        ['q', 'tag'],
+        ['status', 'region_id', 'group_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['tag']
     ]
     q = forms.CharField(
         required=False,
@@ -500,7 +519,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
     group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Group'),
+        label=_('Site group'),
         fetch_trigger='open'
     )
     tag = TagFilterField(model)
@@ -607,11 +626,18 @@ class LocationFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
         label=_('Region'),
         fetch_trigger='open'
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         label=_('Site'),
         fetch_trigger='open'
@@ -897,9 +923,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
     model = Rack
     field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
     field_groups = [
-        ['q'],
-        ['status', 'role_id'],
+        ['q', 'tag'],
         ['region_id', 'site_id', 'location_id'],
+        ['status', 'role_id'],
+        ['type', 'width', 'asset_tag'],
         ['tenant_group_id', 'tenant_id'],
     ]
     q = forms.CharField(
@@ -1134,9 +1161,10 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo
     model = RackReservation
     field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
+        ['user_id'],
         ['region_id', 'site_id', 'location_id'],
-        ['user_id', 'tenant_group_id', 'tenant_id'],
+        ['tenant_group_id', 'tenant_id'],
     ]
     q = forms.CharField(
         required=False,
@@ -1155,7 +1183,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Region'),
+        label=_('Site'),
         fetch_trigger='open'
     )
     location_id = DynamicModelMultipleChoiceField(
@@ -1292,12 +1320,9 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
 class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = DeviceType
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['manufacturer_id', 'subdevice_role'],
-        ['console_ports', 'console_server_ports'],
-        ['power_ports', 'power_outlets'],
-        ['interfaces', 'pass_through_ports'],
-        ['tag']
+        ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
     ]
     q = forms.CharField(
         required=False,
@@ -2526,12 +2551,15 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
         'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
     ]
     field_groups = [
-        ['q'],
-        ['region_id', 'site_id', 'location_id', 'rack_id'],
-        ['status', 'role_id', 'asset_tag'],
+        ['q', 'tag'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
+        ['status', 'role_id', 'asset_tag', 'mac_address'],
+        ['manufacturer_id', 'device_type_id', 'platform_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['manufacturer_id', 'device_type_id'],
-        ['mac_address', 'has_primary_ip'],
+        [
+            'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports',
+            'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
+        ],
     ]
     q = forms.CharField(
         required=False,
@@ -2547,7 +2575,8 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group')
+        label=_('Site group'),
+        fetch_trigger='open'
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -2723,11 +2752,9 @@ class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentFor
 class ConsolePortFilterForm(DeviceComponentFilterForm):
     model = ConsolePort
     field_groups = [
-        ['q'],
-        ['name', 'label'],
-        ['type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag']
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'speed'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=ConsolePortTypeChoices,
@@ -2831,11 +2858,9 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm):
 class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
     model = ConsoleServerPort
     field_groups = [
-        ['q'],
-        ['name', 'label'],
-        ['type', 'speed'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag']
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'speed'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=ConsolePortTypeChoices,
@@ -2939,10 +2964,9 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm):
 class PowerPortFilterForm(DeviceComponentFilterForm):
     model = PowerPort
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=PowerPortTypeChoices,
@@ -3045,10 +3069,9 @@ class PowerPortCSVForm(CustomFieldModelCSVForm):
 class PowerOutletFilterForm(DeviceComponentFilterForm):
     model = PowerOutlet
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['name', 'label', 'type'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=PowerOutletTypeChoices,
@@ -3218,11 +3241,9 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm):
 class InterfaceFilterForm(DeviceComponentFilterForm):
     model = Interface
     field_groups = [
-        ['q'],
-        ['name', 'label', 'type', 'enabled'],
-        ['mgmt_only', 'mac_address'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag'],
+        ['q', 'tag'],
+        ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=InterfaceTypeChoices,
@@ -3578,10 +3599,9 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
 
 class FrontPortFilterForm(DeviceComponentFilterForm):
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag']
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     model = FrontPort
     type = forms.MultipleChoiceField(
@@ -3768,10 +3788,9 @@ class FrontPortCSVForm(CustomFieldModelCSVForm):
 class RearPortFilterForm(DeviceComponentFilterForm):
     model = RearPort
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['name', 'label', 'type', 'color'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag']
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     type = forms.MultipleChoiceField(
         choices=PortTypeChoices,
@@ -3870,10 +3889,9 @@ class RearPortCSVForm(CustomFieldModelCSVForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     model = DeviceBay
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['name', 'label'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag']
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     tag = TagFilterField(model)
 
@@ -4122,11 +4140,9 @@ class InventoryItemBulkEditForm(
 class InventoryItemFilterForm(DeviceComponentFilterForm):
     model = InventoryItem
     field_groups = [
-        ['q'],
-        ['name', 'label', 'manufacturer_id'],
-        ['serial', 'asset_tag', 'discovered'],
-        ['region_id', 'site_group_id', 'site_id'],
-        ['tag']
+        ['q', 'tag'],
+        ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
     ]
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
@@ -4598,11 +4614,10 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
 class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = Cable
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
+        ['site_id', 'rack_id', 'device_id'],
         ['type', 'status', 'color'],
-        ['device_id', 'rack_id'],
-        ['region_id', 'site_id', 'tenant_id'],
-        ['tag']
+        ['tenant_id'],
     ]
     q = forms.CharField(
         required=False,
@@ -4672,11 +4687,6 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
 #
 
 class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -4704,11 +4714,6 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -4736,11 +4741,6 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
-    q = forms.CharField(
-        required=False,
-        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
-        label=_('Search')
-    )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
@@ -5010,10 +5010,9 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
     model = VirtualChassis
     field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['tag']
     ]
     q = forms.CharField(
         required=False,
@@ -5036,7 +5035,8 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         label=_('Site'),
         fetch_trigger='open'
@@ -5159,6 +5159,10 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
 
 class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = PowerPanel
+    field_groups = (
+        ('q', 'tag'),
+        ('region_id', 'site_group_id', 'site_id', 'location_id')
+    )
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
@@ -5180,7 +5184,8 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
         queryset=Site.objects.all(),
         required=False,
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         label=_('Site'),
         fetch_trigger='open'
@@ -5402,12 +5407,10 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
 class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = PowerFeed
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id'],
         ['power_panel_id', 'rack_id'],
-        ['type', 'supply', 'max_utilization'],
-        ['phase', 'voltage', 'amperage'],
-        ['status', 'tag']
+        ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'],
     ]
     q = forms.CharField(
         required=False,

+ 167 - 33
netbox/dcim/tests/test_filtersets.py

@@ -1512,10 +1512,18 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
             Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
@@ -1584,6 +1592,13 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_cabled(self):
         params = {'cabled': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1624,10 +1639,18 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
             Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
@@ -1689,6 +1712,13 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1736,10 +1766,18 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
             Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
@@ -1809,6 +1847,13 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1856,10 +1901,18 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
             Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
@@ -1925,6 +1978,13 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1972,10 +2032,18 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
             Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
@@ -2082,6 +2150,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2143,10 +2218,18 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
             Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
@@ -2217,6 +2300,13 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2264,10 +2354,18 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
             Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]),  # For cable connections
         )
         Device.objects.bulk_create(devices)
@@ -2332,6 +2430,13 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2379,10 +2484,18 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
         device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
         )
         Device.objects.bulk_create(devices)
 
@@ -2426,6 +2539,13 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2474,10 +2594,18 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Site.objects.bulk_create(sites)
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         devices = (
-            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
-            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
-            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
         )
         Device.objects.bulk_create(devices)
 
@@ -2541,13 +2669,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_device(self):
-        # TODO: Allow multiple values
-        device = Device.objects.first()
-        params = {'device_id': device.pk}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        params = {'device': device.name}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        devices = Device.objects.all()[:2]
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'device': [devices[0].name, devices[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_parent_id(self):
         parent_items = InventoryItem.objects.filter(parent__isnull=True)[:2]

+ 21 - 20
netbox/extras/forms.py

@@ -88,12 +88,14 @@ class CustomFieldFilterForm(BootstrapMixin, forms.Form):
     )
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields')
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
     )
     type = forms.MultipleChoiceField(
         choices=CustomFieldTypeChoices,
         required=False,
-        widget=StaticSelectMultiple()
+        widget=StaticSelectMultiple(),
+        label=_('Field type')
     )
     weight = forms.IntegerField(
         required=False
@@ -174,8 +176,7 @@ class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
 class CustomLinkFilterForm(BootstrapMixin, forms.Form):
     field_groups = [
         ['q'],
-        ['content_type'],
-        ['weight', 'new_window'],
+        ['content_type', 'weight', 'new_window'],
     ]
     q = forms.CharField(
         required=False,
@@ -184,7 +185,8 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form):
     )
     content_type = ContentTypeChoiceField(
         queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields')
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
     )
     weight = forms.IntegerField(
         required=False
@@ -265,8 +267,7 @@ class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
 class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
     field_groups = [
         ['q'],
-        ['content_type', 'mime_type'],
-        ['file_extension', 'as_attachment'],
+        ['content_type', 'mime_type', 'file_extension', 'as_attachment'],
     ]
     q = forms.CharField(
         required=False,
@@ -275,10 +276,12 @@ class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
     )
     content_type = ContentTypeChoiceField(
         queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields')
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
     )
     mime_type = forms.CharField(
-        required=False
+        required=False,
+        label=_('MIME type')
     )
     file_extension = forms.CharField(
         required=False
@@ -377,8 +380,8 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
 class WebhookFilterForm(BootstrapMixin, forms.Form):
     field_groups = [
         ['q'],
-        ['content_types', 'http_method'],
-        ['enabled', 'type_create', 'type_update', 'type_delete'],
+        ['content_types', 'http_method', 'enabled'],
+        ['type_create', 'type_update', 'type_delete'],
     ]
     q = forms.CharField(
         required=False,
@@ -387,12 +390,14 @@ class WebhookFilterForm(BootstrapMixin, forms.Form):
     )
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
-        limit_choices_to=FeatureQuery('custom_fields')
+        limit_choices_to=FeatureQuery('custom_fields'),
+        required=False
     )
     http_method = forms.MultipleChoiceField(
         choices=WebhookHttpMethodChoices,
         required=False,
-        widget=StaticSelectMultiple()
+        widget=StaticSelectMultiple(),
+        label=_('HTTP method')
     )
     enabled = forms.NullBooleanField(
         required=False,
@@ -693,16 +698,12 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
 
 
 class ConfigContextFilterForm(BootstrapMixin, forms.Form):
-    field_order = [
-        'q', 'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id',
-        'cluster_id', 'tenant_group_id', 'tenant_id',
-    ]
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id'],
-        ['device_type_id', 'role_id', 'platform_id'],
+        ['device_type_id', 'platform_id', 'role_id'],
         ['cluster_group_id', 'cluster_id'],
-        ['tenant_group_id', 'tenant_id', 'tag']
+        ['tenant_group_id', 'tenant_id']
     ]
     q = forms.CharField(
         required=False,

+ 21 - 33
netbox/ipam/forms.py

@@ -107,12 +107,10 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
 
 class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = VRF
-    field_order = ['q', 'import_target_id', 'export_target_id', 'tenant_group_id', 'tenant_id']
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['import_target_id', 'export_target_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['tag']
     ]
     q = forms.CharField(
         required=False,
@@ -186,9 +184,8 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
 
 class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = RouteTarget
-    field_order = ['q', 'name', 'tenant_group_id', 'tenant_id', 'importing_vrfs', 'exporting_vrfs']
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['importing_vrf_id', 'exporting_vrf_id'],
         ['tenant_group_id', 'tenant_id'],
     ]
@@ -348,9 +345,8 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
 
 class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = Aggregate
-    field_order = ['q', 'family', 'rir', 'tenant_group_id', 'tenant_id']
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['family', 'rir_id'],
         ['tenant_group_id', 'tenant_id']
     ]
@@ -628,17 +624,13 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
 
 class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = Prefix
-    field_order = [
-        'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status',
-        'region_id', 'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id',
-        'is_pool', 'mark_utilized',
-    ]
     field_groups = [
-        ['q'],
-        ['role_id', 'within_include', 'family', 'mask_length'],
-        ['vrf_id', 'present_in_vrf_id', 'is_pool', 'mark_utilized'],
+        ['q', 'tag'],
+        ['within_include', 'family', 'status', 'role_id'],
+        ['vrf_id', 'present_in_vrf_id'],
+        ['mask_length', 'is_pool', 'mark_utilized'],
         ['region_id', 'site_group_id', 'site_id'],
-        ['tenant_group_id', 'tenant_id', 'status', 'tag']
+        ['tenant_group_id', 'tenant_id']
     ]
     q = forms.CharField(
         required=False,
@@ -838,13 +830,10 @@ class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
 
 class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = IPRange
-    field_order = [
-        'q', 'family', 'vrf_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
-    ]
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['family', 'vrf_id', 'status', 'role_id'],
-        ['tenant_group_id', 'tenant_id', 'tag'],
+        ['tenant_group_id', 'tenant_id'],
     ]
     q = forms.CharField(
         required=False,
@@ -1280,10 +1269,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
         'assigned_to_interface', 'tenant_group_id', 'tenant_id',
     ]
     field_groups = [
-        ['q'],
-        ['parent', 'family', 'mask_length'],
-        ['status', 'vrf_id', 'present_in_vrf_id'],
-        ['role', 'assigned_to_interface'],
+        ['q', 'tag'],
+        ['parent', 'family', 'status', 'role'],
+        ['vrf_id', 'present_in_vrf_id'],
+        ['mask_length', 'assigned_to_interface'],
         ['tenant_group_id', 'tenant_id'],
     ]
     q = forms.CharField(
@@ -1489,8 +1478,7 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
     field_groups = [
         ['q'],
-        ['region', 'sitegroup', 'site'],
-        ['location', 'rack']
+        ['region', 'sitegroup', 'site', 'location', 'rack']
     ]
     q = forms.CharField(
         required=False,
@@ -1707,14 +1695,10 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
 
 class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = VLAN
-    field_order = [
-        'q', 'region_id', 'site_group_id', 'site_id', 'group_id', 'status', 'role_id',
-        'tenant_group_id', 'tenant_id',
-    ]
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['region_id', 'site_group_id', 'site_id'],
-        ['group_id', 'role_id', 'status'],
+        ['group_id', 'status', 'role_id'],
         ['tenant_group_id', 'tenant_id'],
     ]
     q = forms.CharField(
@@ -1818,6 +1802,10 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
 
 class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = Service
+    field_groups = (
+        ('q', 'tag'),
+        ('protocol', 'port'),
+    )
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),

+ 43 - 33
netbox/templates/inc/filter_list.html

@@ -4,42 +4,52 @@
 <form action="." method="get">
   <div class="card">
     <div class="card-body overflow-visible d-flex flex-wrap justify-content-between py-3">
-          {% for field in filter_form.hidden_fields %}
-              {{ field }}
-          {% endfor %}
-          {% if filter_form.field_groups %}
-              {% for group in filter_form.field_groups %}
-                  <div class="col col-12">
-                      {% for name in group %}
-                          {% with field=filter_form|get_item:name %}
-                              {% render_field field %}
-                          {% endwith %}
-                      {% endfor %}
-                  </div>
-                  <hr class="card-divider mt-0" />
-              {% endfor %}
-              {% for name in filter_form.custom_field_filters %}
-                <div class="col col-12">
-                  {% with field=filter_form|get_item:name %}
-                    {% render_field field %}
-                  {% endwith %}
-                </div>
-              {% endfor %}
-          {% else %}
-              {% for field in filter_form.visible_fields %}
-                  <div class="col col-12">
-                      {% render_field field %}
-                  </div>
-              {% endfor %}
+      {% for field in filter_form.hidden_fields %}
+        {{ field }}
+      {% endfor %}
+      {% if filter_form.field_groups %}
+        {# List filters by group #}
+        {% for group in filter_form.field_groups %}
+          <div class="col col-12">
+            {% for name in group %}
+              {% with field=filter_form|get_item:name %}
+                {% render_field field %}
+              {% endwith %}
+            {% endfor %}
+          </div>
+          {% if not forloop.last %}
+            <hr class="card-divider mt-0" />
           {% endif %}
+        {% endfor %}
+      {% else %}
+        {# List all non-customfield filters as declared in the form class #}
+        {% for field in filter_form.visible_fields %}
+          {% if not filter_form.custom_field_filters or field.name not in filter_form.custom_field_filters %}
+            <div class="col col-12">
+              {% render_field field %}
+            </div>
+          {% endif %}
+        {% endfor %}
+      {% endif %}
+      {% if filter_form.custom_field_filters %}
+        {# List all custom field filters #}
+        <hr class="card-divider mt-0" />
+        {% for name in filter_form.custom_field_filters %}
+          <div class="col col-12">
+            {% with field=filter_form|get_item:name %}
+              {% render_field field %}
+            {% endwith %}
+          </div>
+        {% endfor %}
+      {% endif %}
     </div>
     <div class="card-footer text-end noprint border-0">
-        <button type="button" class="btn btn-sm btn-outline-danger m-1" data-reset-select>
-            <i class="mdi mdi-backspace"></i> Reset
-        </button>
-        <button type="submit" class="btn btn-sm btn-primary m-1">
-            <i class="mdi mdi-filter-variant"></i> Filter
-        </button>
+      <button type="button" class="btn btn-sm btn-outline-danger m-1" data-reset-select>
+        <i class="mdi mdi-backspace"></i> Reset
+      </button>
+      <button type="submit" class="btn btn-sm btn-primary m-1">
+        <i class="mdi mdi-filter-variant"></i> Filter
+      </button>
     </div>
   </div>
 </form>

+ 4 - 0
netbox/tenancy/forms.py

@@ -135,6 +135,10 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
 
 class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = Tenant
+    field_groups = (
+        ('q', 'tag'),
+        ('group_id',),
+    )
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),

+ 15 - 16
netbox/virtualization/forms.py

@@ -228,11 +228,10 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
         'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id',
     ]
     field_groups = [
-        ['q'],
-        ['type_id'],
-        ['region_id', 'site_id'],
+        ['q', 'tag'],
+        ['group_id', 'type_id'],
+        ['region_id', 'site_group_id', 'site_id'],
         ['tenant_group_id', 'tenant_id'],
-        ['tag'],
     ]
     q = forms.CharField(
         required=False,
@@ -251,12 +250,19 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
         label=_('Region'),
         fetch_trigger='open'
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group'),
+        fetch_trigger='open'
+    )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
         null_option='None',
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'site_group_id': '$site_group_id',
         },
         label=_('Site'),
         fetch_trigger='open'
@@ -541,18 +547,12 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
 
 class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
     model = VirtualMachine
-    field_order = [
-        'q', 'cluster_group_id', 'cluster_type_id', 'cluster_id', 'status', 'role_id', 'region_id', 'site_group_id',
-        'site_id', 'tenant_group_id', 'tenant_id', 'platform_id', 'mac_address',
-    ]
     field_groups = [
-        ['q'],
-        ['status', 'role_id'],
-        ['platform_id', 'mac_address'],
+        ['q', 'tag'],
         ['cluster_group_id', 'cluster_type_id', 'cluster_id'],
-        ['region_id', 'site_id'],
+        ['region_id', 'site_group_id', 'site_id'],
+        ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip'],
         ['tenant_group_id', 'tenant_id'],
-
     ]
     q = forms.CharField(
         required=False,
@@ -878,10 +878,9 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
 class VMInterfaceFilterForm(BootstrapMixin, forms.Form):
     model = VMInterface
     field_groups = [
-        ['q'],
+        ['q', 'tag'],
         ['cluster_id', 'virtual_machine_id'],
         ['enabled', 'mac_address'],
-        ['tag']
     ]
     q = forms.CharField(
         required=False,