Explorar o código

Closes #9623: Implement saved filters (#10801)

* Initial work on saved filters

* Return only enabled/shared filters

* Add tests

* Clean up filtering of usable SavedFilters
Jeremy Stretch %!s(int64=3) %!d(string=hai) anos
pai
achega
484efdaf75
Modificáronse 37 ficheiros con 821 adicións e 138 borrados
  1. 3 3
      netbox/circuits/forms/filtersets.py
  2. 25 25
      netbox/dcim/forms/filtersets.py
  3. 9 0
      netbox/extras/api/nested_serializers.py
  4. 20 0
      netbox/extras/api/serializers.py
  5. 1 25
      netbox/extras/api/urls.py
  6. 12 0
      netbox/extras/api/views.py
  7. 50 0
      netbox/extras/filtersets.py
  8. 1 1
      netbox/extras/forms/__init__.py
  9. 26 3
      netbox/extras/forms/bulk_edit.py
  10. 14 0
      netbox/extras/forms/bulk_import.py
  11. 45 16
      netbox/extras/forms/filtersets.py
  12. 14 0
      netbox/extras/forms/mixins.py
  13. 30 0
      netbox/extras/forms/model_forms.py
  14. 3 0
      netbox/extras/graphql/schema.py
  15. 9 0
      netbox/extras/graphql/types.py
  16. 36 0
      netbox/extras/migrations/0083_savedfilter.py
  17. 1 0
      netbox/extras/models/__init__.py
  18. 65 1
      netbox/extras/models/models.py
  19. 19 23
      netbox/extras/tables/tables.py
  20. 67 2
      netbox/extras/tests/test_api.py
  21. 86 0
      netbox/extras/tests/test_filtersets.py
  22. 52 0
      netbox/extras/tests/test_views.py
  23. 8 0
      netbox/extras/urls.py
  24. 68 1
      netbox/extras/views.py
  25. 17 16
      netbox/ipam/forms/filtersets.py
  26. 20 3
      netbox/netbox/filtersets.py
  27. 11 2
      netbox/netbox/forms/base.py
  28. 1 0
      netbox/netbox/navigation/menu.py
  29. 4 5
      netbox/netbox/views/generic/bulk_views.py
  30. 70 0
      netbox/templates/extras/savedfilter.html
  31. 1 1
      netbox/templates/generic/object_list.html
  32. 1 1
      netbox/tenancy/forms/filtersets.py
  33. 5 0
      netbox/utilities/templates/helpers/applied_filters.html
  34. 14 3
      netbox/utilities/templatetags/helpers.py
  35. 7 1
      netbox/utilities/testing/base.py
  36. 4 4
      netbox/virtualization/forms/filtersets.py
  37. 2 2
      netbox/wireless/forms/filtersets.py

+ 3 - 3
netbox/circuits/forms/filtersets.py

@@ -20,7 +20,7 @@ __all__ = (
 class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Provider
     model = Provider
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('ASN', ('asn',)),
         ('ASN', ('asn',)),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
 class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     model = ProviderNetwork
     model = ProviderNetwork
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('provider_id', 'service_id')),
         ('Attributes', ('provider_id', 'service_id')),
     )
     )
     provider_id = DynamicModelMultipleChoiceField(
     provider_id = DynamicModelMultipleChoiceField(
@@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
 class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Circuit
     model = Circuit
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Provider', ('provider_id', 'provider_network_id')),
         ('Provider', ('provider_id', 'provider_network_id')),
         ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
         ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),

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

@@ -116,7 +116,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
 class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Region
     model = Region
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag', 'parent_id')),
+        (None, ('q', 'filter', 'tag', 'parent_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
@@ -130,7 +130,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = SiteGroup
     model = SiteGroup
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag', 'parent_id')),
+        (None, ('q', 'filter', 'tag', 'parent_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     )
     parent_id = DynamicModelMultipleChoiceField(
     parent_id = DynamicModelMultipleChoiceField(
@@ -144,7 +144,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Site
     model = Site
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
         ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -174,7 +174,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
 class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Location
     model = Location
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
         ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -222,7 +222,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
 class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Rack
     model = Rack
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Function', ('status', 'role_id')),
         ('Function', ('status', 'role_id')),
         ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
         ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
@@ -306,7 +306,7 @@ class RackElevationFilterForm(RackFilterForm):
 class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RackReservation
     model = RackReservation
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('User', ('user_id',)),
         ('User', ('user_id',)),
         ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -362,7 +362,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Manufacturer
     model = Manufacturer
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
@@ -371,7 +371,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
 class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
     model = DeviceType
     model = DeviceType
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
         ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
         ('Images', ('has_front_image', 'has_rear_image')),
         ('Images', ('has_front_image', 'has_rear_image')),
         ('Components', (
         ('Components', (
@@ -486,7 +486,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
 class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
 class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     model = ModuleType
     model = ModuleType
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Hardware', ('manufacturer_id', 'part_number')),
         ('Hardware', ('manufacturer_id', 'part_number')),
         ('Components', (
         ('Components', (
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
@@ -578,7 +578,7 @@ class DeviceFilterForm(
 ):
 ):
     model = Device
     model = Device
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
         ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
         ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
         ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
@@ -731,7 +731,7 @@ class DeviceFilterForm(
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Module
     model = Module
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
         ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
     )
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
@@ -761,7 +761,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
 class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VirtualChassis
     model = VirtualChassis
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -790,7 +790,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Cable
     model = Cable
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
         ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
         ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
         ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -862,7 +862,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = PowerPanel
     model = PowerPanel
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     )
@@ -900,7 +900,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class PowerFeedFilterForm(NetBoxModelFilterSetForm):
 class PowerFeedFilterForm(NetBoxModelFilterSetForm):
     model = PowerFeed
     model = PowerFeed
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
         ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
         ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
     )
     )
@@ -1002,7 +1002,7 @@ class PathEndpointFilterForm(CabledFilterForm):
 class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsolePort
     model = ConsolePort
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1021,7 +1021,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsoleServerPort
     model = ConsoleServerPort
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1040,7 +1040,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
 class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerPort
     model = PowerPort
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('Attributes', ('name', 'label', 'type')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1055,7 +1055,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerOutlet
     model = PowerOutlet
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('Attributes', ('name', 'label', 'type')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1070,7 +1070,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     model = Interface
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
         ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
         ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
         ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
         ('PoE', ('poe_mode', 'poe_type')),
         ('PoE', ('poe_mode', 'poe_type')),
@@ -1159,7 +1159,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 
 
 class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Cable', ('cabled', 'occupied')),
         ('Cable', ('cabled', 'occupied')),
@@ -1178,7 +1178,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     model = RearPort
     model = RearPort
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Cable', ('cabled', 'occupied')),
         ('Cable', ('cabled', 'occupied')),
@@ -1196,7 +1196,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
     model = ModuleBay
     model = ModuleBay
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'position')),
         ('Attributes', ('name', 'label', 'position')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
     )
     )
@@ -1209,7 +1209,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     model = DeviceBay
     model = DeviceBay
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label')),
         ('Attributes', ('name', 'label')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
     )
     )
@@ -1219,7 +1219,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
 class InventoryItemFilterForm(DeviceComponentFilterForm):
 class InventoryItemFilterForm(DeviceComponentFilterForm):
     model = InventoryItem
     model = InventoryItem
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
         ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
     )
     )

+ 9 - 0
netbox/extras/api/nested_serializers.py

@@ -13,6 +13,7 @@ __all__ = [
     'NestedImageAttachmentSerializer',
     'NestedImageAttachmentSerializer',
     'NestedJobResultSerializer',
     'NestedJobResultSerializer',
     'NestedJournalEntrySerializer',
     'NestedJournalEntrySerializer',
+    'NestedSavedFilterSerializer',
     'NestedTagSerializer',  # Defined in netbox.api.serializers
     'NestedTagSerializer',  # Defined in netbox.api.serializers
     'NestedWebhookSerializer',
     'NestedWebhookSerializer',
 ]
 ]
@@ -58,6 +59,14 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
         fields = ['id', 'url', 'display', 'name']
 
 
 
 
+class NestedSavedFilterSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
+
+    class Meta:
+        model = models.SavedFilter
+        fields = ['id', 'url', 'display', 'name']
+
+
 class NestedImageAttachmentSerializer(WritableNestedSerializer):
 class NestedImageAttachmentSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
 
 

+ 20 - 0
netbox/extras/api/serializers.py

@@ -39,6 +39,7 @@ __all__ = (
     'ReportDetailSerializer',
     'ReportDetailSerializer',
     'ReportSerializer',
     'ReportSerializer',
     'ReportInputSerializer',
     'ReportInputSerializer',
+    'SavedFilterSerializer',
     'ScriptDetailSerializer',
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
     'ScriptInputSerializer',
     'ScriptLogMessageSerializer',
     'ScriptLogMessageSerializer',
@@ -149,6 +150,25 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
         ]
         ]
 
 
 
 
+#
+# Saved filters
+#
+
+class SavedFilterSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail')
+    content_types = ContentTypeField(
+        queryset=ContentType.objects.all(),
+        many=True
+    )
+
+    class Meta:
+        model = SavedFilter
+        fields = [
+            'id', 'url', 'display', 'content_types', 'name', 'description', 'user', 'weight',
+            'enabled', 'shared', 'parameters', 'created', 'last_updated',
+        ]
+
+
 #
 #
 # Tags
 # Tags
 #
 #

+ 1 - 25
netbox/extras/api/urls.py

@@ -5,43 +5,19 @@ from . import views
 router = NetBoxRouter()
 router = NetBoxRouter()
 router.APIRootView = views.ExtrasRootView
 router.APIRootView = views.ExtrasRootView
 
 
-# Webhooks
 router.register('webhooks', views.WebhookViewSet)
 router.register('webhooks', views.WebhookViewSet)
-
-# Custom fields
 router.register('custom-fields', views.CustomFieldViewSet)
 router.register('custom-fields', views.CustomFieldViewSet)
-
-# Custom links
 router.register('custom-links', views.CustomLinkViewSet)
 router.register('custom-links', views.CustomLinkViewSet)
-
-# Export templates
 router.register('export-templates', views.ExportTemplateViewSet)
 router.register('export-templates', views.ExportTemplateViewSet)
-
-# Tags
+router.register('saved-filters', views.SavedFilterViewSet)
 router.register('tags', views.TagViewSet)
 router.register('tags', views.TagViewSet)
-
-# Image attachments
 router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('image-attachments', views.ImageAttachmentViewSet)
-
-# Journal entries
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
-
-# Config contexts
 router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
-
-# Reports
 router.register('reports', views.ReportViewSet, basename='report')
 router.register('reports', views.ReportViewSet, basename='report')
-
-# Scripts
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('scripts', views.ScriptViewSet, basename='script')
-
-# Change logging
 router.register('object-changes', views.ObjectChangeViewSet)
 router.register('object-changes', views.ObjectChangeViewSet)
-
-# Job Results
 router.register('job-results', views.JobResultViewSet)
 router.register('job-results', views.JobResultViewSet)
-
-# ContentTypes
 router.register('content-types', views.ContentTypeViewSet)
 router.register('content-types', views.ContentTypeViewSet)
 
 
 app_name = 'extras-api'
 app_name = 'extras-api'

+ 12 - 0
netbox/extras/api/views.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
 from django.http import Http404
 from django.http import Http404
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
@@ -98,6 +99,17 @@ class ExportTemplateViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.ExportTemplateFilterSet
     filterset_class = filtersets.ExportTemplateFilterSet
 
 
 
 
+#
+# Saved filters
+#
+
+class SavedFilterViewSet(NetBoxModelViewSet):
+    metadata_class = ContentTypeMetadata
+    queryset = SavedFilter.objects.all()
+    serializer_class = serializers.SavedFilterSerializer
+    filterset_class = filtersets.SavedFilterFilterSet
+
+
 #
 #
 # Tags
 # Tags
 #
 #

+ 50 - 0
netbox/extras/filtersets.py

@@ -23,6 +23,7 @@ __all__ = (
     'JournalEntryFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'ObjectChangeFilterSet',
+    'SavedFilterFilterSet',
     'TagFilterSet',
     'TagFilterSet',
     'WebhookFilterSet',
     'WebhookFilterSet',
 )
 )
@@ -138,6 +139,55 @@ class ExportTemplateFilterSet(BaseFilterSet):
         )
         )
 
 
 
 
+class SavedFilterFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    content_type_id = MultiValueNumberFilter(
+        field_name='content_types__id'
+    )
+    content_types = ContentTypeFilter()
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=User.objects.all(),
+        label='User (ID)',
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='user__username',
+        queryset=User.objects.all(),
+        to_field_name='username',
+        label='User (name)',
+    )
+    usable = django_filters.BooleanFilter(
+        method='_usable'
+    )
+
+    class Meta:
+        model = SavedFilter
+        fields = ['id', 'content_types', 'name', 'description', 'enabled', 'shared', 'weight']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+
+    def _usable(self, queryset, name, value):
+        """
+        Return only SavedFilters that are both enabled and are shared (or belong to the current user).
+        """
+        user = self.request.user if self.request else None
+        if not user or user.is_anonymous:
+            if value:
+                return queryset.filter(enabled=True, shared=True)
+            return queryset.filter(Q(enabled=False) | Q(shared=False))
+        if value:
+            return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user))
+        return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
+
+
 class ImageAttachmentFilterSet(BaseFilterSet):
 class ImageAttachmentFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 1 - 1
netbox/extras/forms/__init__.py

@@ -2,6 +2,6 @@ from .model_forms import *
 from .filtersets import *
 from .filtersets import *
 from .bulk_edit import *
 from .bulk_edit import *
 from .bulk_import import *
 from .bulk_import import *
-from .customfields import *
+from .mixins import *
 from .config import *
 from .config import *
 from .scripts import *
 from .scripts import *

+ 26 - 3
netbox/extras/forms/bulk_edit.py

@@ -1,11 +1,9 @@
 from django import forms
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 
 
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
+    add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect,
 )
 )
 
 
 __all__ = (
 __all__ = (
@@ -14,6 +12,7 @@ __all__ = (
     'CustomLinkBulkEditForm',
     'CustomLinkBulkEditForm',
     'ExportTemplateBulkEditForm',
     'ExportTemplateBulkEditForm',
     'JournalEntryBulkEditForm',
     'JournalEntryBulkEditForm',
+    'SavedFilterBulkEditForm',
     'TagBulkEditForm',
     'TagBulkEditForm',
     'WebhookBulkEditForm',
     'WebhookBulkEditForm',
 )
 )
@@ -96,6 +95,30 @@ class ExportTemplateBulkEditForm(BulkEditForm):
     nullable_fields = ('description', 'mime_type', 'file_extension')
     nullable_fields = ('description', 'mime_type', 'file_extension')
 
 
 
 
+class SavedFilterBulkEditForm(BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=SavedFilter.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    shared = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+
+    nullable_fields = ('description',)
+
+
 class WebhookBulkEditForm(BulkEditForm):
 class WebhookBulkEditForm(BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Webhook.objects.all(),
         queryset=Webhook.objects.all(),

+ 14 - 0
netbox/extras/forms/bulk_import.py

@@ -12,6 +12,7 @@ __all__ = (
     'CustomFieldCSVForm',
     'CustomFieldCSVForm',
     'CustomLinkCSVForm',
     'CustomLinkCSVForm',
     'ExportTemplateCSVForm',
     'ExportTemplateCSVForm',
+    'SavedFilterCSVForm',
     'TagCSVForm',
     'TagCSVForm',
     'WebhookCSVForm',
     'WebhookCSVForm',
 )
 )
@@ -81,6 +82,19 @@ class ExportTemplateCSVForm(CSVModelForm):
         )
         )
 
 
 
 
+class SavedFilterCSVForm(CSVModelForm):
+    content_types = CSVMultipleContentTypeField(
+        queryset=ContentType.objects.all(),
+        help_text="One or more assigned object types"
+    )
+
+    class Meta:
+        model = SavedFilter
+        fields = (
+            'name', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters',
+        )
+
+
 class WebhookCSVForm(CSVModelForm):
 class WebhookCSVForm(CSVModelForm):
     content_types = CSVMultipleContentTypeField(
     content_types = CSVMultipleContentTypeField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),

+ 45 - 16
netbox/extras/forms/filtersets.py

@@ -15,6 +15,7 @@ from utilities.forms import (
     StaticSelect, TagFilterField,
     StaticSelect, TagFilterField,
 )
 )
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
+from .mixins import SavedFiltersMixin
 
 
 __all__ = (
 __all__ = (
     'ConfigContextFilterForm',
     'ConfigContextFilterForm',
@@ -25,14 +26,15 @@ __all__ = (
     'JournalEntryFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
     'LocalConfigContextFilterForm',
     'ObjectChangeFilterForm',
     'ObjectChangeFilterForm',
+    'SavedFilterFilterForm',
     'TagFilterForm',
     'TagFilterForm',
     'WebhookFilterForm',
     'WebhookFilterForm',
 )
 )
 
 
 
 
-class CustomFieldFilterForm(FilterForm):
+class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
         ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
     )
     )
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
@@ -66,9 +68,9 @@ class CustomFieldFilterForm(FilterForm):
     )
     )
 
 
 
 
-class JobResultFilterForm(FilterForm):
+class JobResultFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('obj_type', 'status')),
         ('Attributes', ('obj_type', 'status')),
         ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
         ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
                       'scheduled_time__before', 'scheduled_time__after', 'user')),
                       'scheduled_time__before', 'scheduled_time__after', 'user')),
@@ -118,9 +120,9 @@ class JobResultFilterForm(FilterForm):
     )
     )
 
 
 
 
-class CustomLinkFilterForm(FilterForm):
+class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
         ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
     )
     )
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
@@ -145,9 +147,9 @@ class CustomLinkFilterForm(FilterForm):
     )
     )
 
 
 
 
-class ExportTemplateFilterForm(FilterForm):
+class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
         ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
     )
     )
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
@@ -170,9 +172,36 @@ class ExportTemplateFilterForm(FilterForm):
     )
     )
 
 
 
 
-class WebhookFilterForm(FilterForm):
+class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
+        ('Attributes', ('content_types', 'enabled', 'shared', 'weight')),
+    )
+    content_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('export_templates'),
+        required=False
+    )
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    shared = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    weight = forms.IntegerField(
+        required=False
+    )
+
+
+class WebhookFilterForm(SavedFiltersMixin, FilterForm):
+    fieldsets = (
+        (None, ('q', 'filter')),
         ('Attributes', ('content_type_id', 'http_method', 'enabled')),
         ('Attributes', ('content_type_id', 'http_method', 'enabled')),
         ('Events', ('type_create', 'type_update', 'type_delete')),
         ('Events', ('type_create', 'type_update', 'type_delete')),
     )
     )
@@ -213,7 +242,7 @@ class WebhookFilterForm(FilterForm):
     )
     )
 
 
 
 
-class TagFilterForm(FilterForm):
+class TagFilterForm(SavedFiltersMixin, FilterForm):
     model = Tag
     model = Tag
     content_type_id = ContentTypeMultipleChoiceField(
     content_type_id = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
         queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
@@ -222,9 +251,9 @@ class TagFilterForm(FilterForm):
     )
     )
 
 
 
 
-class ConfigContextFilterForm(FilterForm):
+class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag_id')),
+        (None, ('q', 'filter', 'tag_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Device', ('device_type_id', 'platform_id', 'role_id')),
         ('Device', ('device_type_id', 'platform_id', 'role_id')),
         ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
         ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
@@ -311,7 +340,7 @@ class LocalConfigContextFilterForm(forms.Form):
 class JournalEntryFilterForm(NetBoxModelFilterSetForm):
 class JournalEntryFilterForm(NetBoxModelFilterSetForm):
     model = JournalEntry
     model = JournalEntry
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Creation', ('created_before', 'created_after', 'created_by_id')),
         ('Creation', ('created_before', 'created_after', 'created_by_id')),
         ('Attributes', ('assigned_object_type_id', 'kind'))
         ('Attributes', ('assigned_object_type_id', 'kind'))
     )
     )
@@ -349,10 +378,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ObjectChangeFilterForm(FilterForm):
+class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
     model = ObjectChange
     model = ObjectChange
     fieldsets = (
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Time', ('time_before', 'time_after')),
         ('Time', ('time_before', 'time_after')),
         ('Attributes', ('action', 'user_id', 'changed_object_type_id')),
         ('Attributes', ('action', 'user_id', 'changed_object_type_id')),
     )
     )

+ 14 - 0
netbox/extras/forms/customfields.py → netbox/extras/forms/mixins.py

@@ -1,10 +1,13 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django import forms
 
 
 from extras.models import *
 from extras.models import *
 from extras.choices import CustomFieldVisibilityChoices
 from extras.choices import CustomFieldVisibilityChoices
+from utilities.forms.fields import DynamicModelMultipleChoiceField
 
 
 __all__ = (
 __all__ = (
     'CustomFieldsMixin',
     'CustomFieldsMixin',
+    'SavedFiltersMixin',
 )
 )
 
 
 
 
@@ -57,3 +60,14 @@ class CustomFieldsMixin:
             if customfield.group_name not in self.custom_field_groups:
             if customfield.group_name not in self.custom_field_groups:
                 self.custom_field_groups[customfield.group_name] = []
                 self.custom_field_groups[customfield.group_name] = []
             self.custom_field_groups[customfield.group_name].append(field_name)
             self.custom_field_groups[customfield.group_name].append(field_name)
+
+
+class SavedFiltersMixin(forms.Form):
+    filter = DynamicModelMultipleChoiceField(
+        queryset=SavedFilter.objects.all(),
+        required=False,
+        label='Saved Filter',
+        query_params={
+            'usable': True,
+        }
+    )

+ 30 - 0
netbox/extras/forms/model_forms.py

@@ -1,5 +1,6 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.http import QueryDict
 
 
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.choices import *
@@ -20,6 +21,7 @@ __all__ = (
     'ExportTemplateForm',
     'ExportTemplateForm',
     'ImageAttachmentForm',
     'ImageAttachmentForm',
     'JournalEntryForm',
     'JournalEntryForm',
+    'SavedFilterForm',
     'TagForm',
     'TagForm',
     'WebhookForm',
     'WebhookForm',
 )
 )
@@ -108,6 +110,34 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
+class SavedFilterForm(BootstrapMixin, forms.ModelForm):
+    content_types = ContentTypeMultipleChoiceField(
+        queryset=ContentType.objects.all()
+    )
+
+    fieldsets = (
+        ('Saved Filter', ('name', 'content_types', 'description', 'weight', 'enabled', 'shared')),
+        ('Parameters', ('parameters',)),
+    )
+
+    class Meta:
+        model = SavedFilter
+        exclude = ('user',)
+        widgets = {
+            'parameters': forms.Textarea(attrs={'class': 'font-monospace'}),
+        }
+
+    def __init__(self, *args, initial=None, **kwargs):
+
+        # Convert any parameters delivered via initial data to a dictionary
+        if initial and 'parameters' in initial:
+            if type(initial['parameters']) is str:
+                # TODO: Make a utility function for this
+                initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
+
+        super().__init__(*args, initial=initial, **kwargs)
+
+
 class WebhookForm(BootstrapMixin, forms.ModelForm):
 class WebhookForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),

+ 3 - 0
netbox/extras/graphql/schema.py

@@ -20,6 +20,9 @@ class ExtrasQuery(graphene.ObjectType):
     image_attachment = ObjectField(ImageAttachmentType)
     image_attachment = ObjectField(ImageAttachmentType)
     image_attachment_list = ObjectListField(ImageAttachmentType)
     image_attachment_list = ObjectListField(ImageAttachmentType)
 
 
+    saved_filter = ObjectField(SavedFilterType)
+    saved_filter_list = ObjectListField(SavedFilterType)
+
     journal_entry = ObjectField(JournalEntryType)
     journal_entry = ObjectField(JournalEntryType)
     journal_entry_list = ObjectListField(JournalEntryType)
     journal_entry_list = ObjectListField(JournalEntryType)
 
 

+ 9 - 0
netbox/extras/graphql/types.py

@@ -10,6 +10,7 @@ __all__ = (
     'ImageAttachmentType',
     'ImageAttachmentType',
     'JournalEntryType',
     'JournalEntryType',
     'ObjectChangeType',
     'ObjectChangeType',
+    'SavedFilterType',
     'TagType',
     'TagType',
     'WebhookType',
     'WebhookType',
 )
 )
@@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType):
         filterset_class = filtersets.ObjectChangeFilterSet
         filterset_class = filtersets.ObjectChangeFilterSet
 
 
 
 
+class SavedFilterType(ObjectType):
+
+    class Meta:
+        model = models.SavedFilter
+        exclude = ('content_types', )
+        filterset_class = filtersets.SavedFilterFilterSet
+
+
 class TagType(ObjectType):
 class TagType(ObjectType):
 
 
     class Meta:
     class Meta:

+ 36 - 0
netbox/extras/migrations/0083_savedfilter.py

@@ -0,0 +1,36 @@
+# Generated by Django 4.1.1 on 2022-10-27 18:18
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0082_exporttemplate_content_types'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SavedFilter',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('weight', models.PositiveSmallIntegerField(default=100)),
+                ('enabled', models.BooleanField(default=True)),
+                ('shared', models.BooleanField(default=True)),
+                ('parameters', models.JSONField()),
+                ('content_types', models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype')),
+                ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ('weight', 'name'),
+            },
+        ),
+    ]

+ 1 - 0
netbox/extras/models/__init__.py

@@ -18,6 +18,7 @@ __all__ = (
     'JournalEntry',
     'JournalEntry',
     'ObjectChange',
     'ObjectChange',
     'Report',
     'Report',
+    'SavedFilter',
     'Script',
     'Script',
     'Tag',
     'Tag',
     'TaggedItem',
     'TaggedItem',

+ 65 - 1
netbox/extras/models/models.py

@@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.cache import cache
 from django.core.validators import ValidationError
 from django.core.validators import ValidationError
 from django.db import models
 from django.db import models
-from django.http import HttpResponse
+from django.http import HttpResponse, QueryDict
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.formats import date_format
 from django.utils.formats import date_format
@@ -34,6 +34,7 @@ __all__ = (
     'JobResult',
     'JobResult',
     'JournalEntry',
     'JournalEntry',
     'Report',
     'Report',
+    'SavedFilter',
     'Script',
     'Script',
     'Webhook',
     'Webhook',
 )
 )
@@ -350,6 +351,69 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
         return response
         return response
 
 
 
 
+class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
+    """
+    A set of predefined keyword parameters that can be reused to filter for specific objects.
+    """
+    content_types = models.ManyToManyField(
+        to=ContentType,
+        related_name='saved_filters',
+        help_text='The object type(s) to which this filter applies.'
+    )
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    user = models.ForeignKey(
+        to=User,
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True
+    )
+    weight = models.PositiveSmallIntegerField(
+        default=100
+    )
+    enabled = models.BooleanField(
+        default=True
+    )
+    shared = models.BooleanField(
+        default=True
+    )
+    parameters = models.JSONField()
+
+    clone_fields = (
+        'enabled', 'weight',
+    )
+
+    class Meta:
+        ordering = ('weight', 'name')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('extras:savedfilter', args=[self.pk])
+
+    def clean(self):
+        super().clean()
+
+        # Verify that `parameters` is a JSON object
+        if type(self.parameters) is not dict:
+            raise ValidationError(
+                {'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'}
+            )
+
+    @property
+    def url_params(self):
+        qd = QueryDict(mutable=True)
+        qd.update(self.parameters)
+        return qd.urlencode()
+
+
 class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
 class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
     """
     """
     An uploaded image which is associated with an object.
     An uploaded image which is associated with an object.

+ 19 - 23
netbox/extras/tables/tables.py

@@ -13,16 +13,13 @@ __all__ = (
     'ExportTemplateTable',
     'ExportTemplateTable',
     'JournalEntryTable',
     'JournalEntryTable',
     'ObjectChangeTable',
     'ObjectChangeTable',
+    'SavedFilterTable',
     'TaggedItemTable',
     'TaggedItemTable',
     'TagTable',
     'TagTable',
     'WebhookTable',
     'WebhookTable',
 )
 )
 
 
 
 
-#
-# Custom fields
-#
-
 class CustomFieldTable(NetBoxTable):
 class CustomFieldTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
@@ -40,10 +37,6 @@ class CustomFieldTable(NetBoxTable):
         default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
         default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
 
 
 
 
-#
-# Custom fields
-#
-
 class JobResultTable(NetBoxTable):
 class JobResultTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
@@ -61,10 +54,6 @@ class JobResultTable(NetBoxTable):
         default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',)
         default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',)
 
 
 
 
-#
-# Custom links
-#
-
 class CustomLinkTable(NetBoxTable):
 class CustomLinkTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
@@ -82,10 +71,6 @@ class CustomLinkTable(NetBoxTable):
         default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window')
         default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window')
 
 
 
 
-#
-# Export templates
-#
-
 class ExportTemplateTable(NetBoxTable):
 class ExportTemplateTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
@@ -104,9 +89,24 @@ class ExportTemplateTable(NetBoxTable):
         )
         )
 
 
 
 
-#
-# Webhooks
-#
+class SavedFilterTable(NetBoxTable):
+    name = tables.Column(
+        linkify=True
+    )
+    content_types = columns.ContentTypesColumn()
+    enabled = columns.BooleanColumn()
+    shared = columns.BooleanColumn()
+
+    class Meta(NetBoxTable.Meta):
+        model = SavedFilter
+        fields = (
+            'pk', 'id', 'name', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
+            'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',
+        )
+
 
 
 class WebhookTable(NetBoxTable):
 class WebhookTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
@@ -139,10 +139,6 @@ class WebhookTable(NetBoxTable):
         )
         )
 
 
 
 
-#
-# Tags
-#
-
 class TagTable(NetBoxTable):
 class TagTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True

+ 67 - 2
netbox/extras/tests/test_api.py

@@ -3,7 +3,6 @@ from unittest import skipIf
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import make_aware
 from django.utils.timezone import make_aware
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
@@ -17,7 +16,6 @@ from extras.reports import Report
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
-
 rq_worker_running = Worker.count(get_connection('default'))
 rq_worker_running = Worker.count(get_connection('default'))
 
 
 
 
@@ -192,6 +190,73 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
             custom_link.content_types.set([site_ct])
             custom_link.content_types.set([site_ct])
 
 
 
 
+class SavedFilterTest(APIViewTestCases.APIViewTestCase):
+    model = SavedFilter
+    brief_fields = ['display', 'id', 'name', 'url']
+    create_data = [
+        {
+            'content_types': ['dcim.site'],
+            'name': 'Saved Filter 4',
+            'weight': 100,
+            'enabled': True,
+            'shared': True,
+            'parameters': {'status': ['active']},
+        },
+        {
+            'content_types': ['dcim.site'],
+            'name': 'Saved Filter 5',
+            'weight': 200,
+            'enabled': True,
+            'shared': True,
+            'parameters': {'status': ['planned']},
+        },
+        {
+            'content_types': ['dcim.site'],
+            'name': 'Saved Filter 6',
+            'weight': 300,
+            'enabled': True,
+            'shared': True,
+            'parameters': {'status': ['retired']},
+        },
+    ]
+    bulk_update_data = {
+        'weight': 1000,
+        'enabled': False,
+        'shared': False,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+
+        saved_filters = (
+            SavedFilter(
+                name='Saved Filter 1',
+                weight=100,
+                enabled=True,
+                shared=True,
+                parameters={'status': ['active']}
+            ),
+            SavedFilter(
+                name='Saved Filter 2',
+                weight=200,
+                enabled=True,
+                shared=True,
+                parameters={'status': ['planned']}
+            ),
+            SavedFilter(
+                name='Saved Filter 3',
+                weight=300,
+                enabled=True,
+                shared=True,
+                parameters={'status': ['retired']}
+            ),
+        )
+        SavedFilter.objects.bulk_create(saved_filters)
+        for i, savedfilter in enumerate(saved_filters):
+            savedfilter.content_types.set([site_ct])
+
+
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
     model = ExportTemplate
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']

+ 86 - 0
netbox/extras/tests/test_filtersets.py

@@ -222,6 +222,92 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
+class SavedFilterTestCase(TestCase, BaseFilterSetTests):
+    queryset = SavedFilter.objects.all()
+    filterset = SavedFilterFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+        )
+        User.objects.bulk_create(users)
+
+        saved_filters = (
+            SavedFilter(
+                name='Saved Filter 1',
+                user=users[0],
+                weight=100,
+                enabled=True,
+                shared=True,
+                parameters={'status': ['active']}
+            ),
+            SavedFilter(
+                name='Saved Filter 2',
+                user=users[1],
+                weight=200,
+                enabled=True,
+                shared=True,
+                parameters={'status': ['planned']}
+            ),
+            SavedFilter(
+                name='Saved Filter 3',
+                user=users[2],
+                weight=300,
+                enabled=False,
+                shared=False,
+                parameters={'status': ['retired']}
+            ),
+        )
+        SavedFilter.objects.bulk_create(saved_filters)
+        for i, savedfilter in enumerate(saved_filters):
+            savedfilter.content_types.set([content_types[i]])
+
+    def test_name(self):
+        params = {'name': ['Saved Filter 1', 'Saved Filter 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_content_types(self):
+        params = {'content_types': 'dcim.site'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_user(self):
+        users = User.objects.filter(username__startswith='User')
+        params = {'user': [users[0].username, users[1].username]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'user_id': [users[0].pk, users[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_weight(self):
+        params = {'weight': [100, 200]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_enabled(self):
+        params = {'enabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'enabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_shared(self):
+        params = {'enabled': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'enabled': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_usable(self):
+        # Filtering for an anonymous user
+        params = {'usable': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'usable': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
 class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
 class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
     filterset = ExportTemplateFilterSet
     filterset = ExportTemplateFilterSet

+ 52 - 0
netbox/extras/tests/test_views.py

@@ -107,6 +107,58 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
+class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = SavedFilter
+
+    @classmethod
+    def setUpTestData(cls):
+        site_ct = ContentType.objects.get_for_model(Site)
+
+        users = (
+            User(username='User 1'),
+            User(username='User 2'),
+            User(username='User 3'),
+        )
+        User.objects.bulk_create(users)
+
+        saved_filters = (
+            SavedFilter(name='Saved Filter 1', user=users[0], weight=100, parameters={'status': ['active']}),
+            SavedFilter(name='Saved Filter 2', user=users[1], weight=200, parameters={'status': ['planned']}),
+            SavedFilter(name='Saved Filter 3', user=users[2], weight=300, parameters={'status': ['retired']}),
+        )
+        SavedFilter.objects.bulk_create(saved_filters)
+        for i, savedfilter in enumerate(saved_filters):
+            savedfilter.content_types.set([site_ct])
+
+        cls.form_data = {
+            'name': 'Saved Filter X',
+            'content_types': [site_ct.pk],
+            'description': 'Foo',
+            'weight': 1000,
+            'enabled': True,
+            'shared': True,
+            'parameters': '{"foo": 123}',
+        }
+
+        cls.csv_data = (
+            'name,content_types,weight,enabled,shared,parameters',
+            'Saved Filter 4,dcim.device,400,True,True,{"foo": "a"}',
+            'Saved Filter 5,dcim.device,500,True,True,{"foo": "b"}',
+            'Saved Filter 6,dcim.device,600,True,True,{"foo": "c"}',
+        )
+
+        cls.csv_update_data = (
+            "id,name",
+            f"{saved_filters[0].pk},Saved Filter 7",
+            f"{saved_filters[1].pk},Saved Filter 8",
+            f"{saved_filters[2].pk},Saved Filter 9",
+        )
+
+        cls.bulk_edit_data = {
+            'weight': 999,
+        }
+
+
 class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ExportTemplate
     model = ExportTemplate
 
 

+ 8 - 0
netbox/extras/urls.py

@@ -31,6 +31,14 @@ urlpatterns = [
     path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
     path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
     path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
     path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
 
 
+    # Saved filters
+    path('saved-filters/', views.SavedFilterListView.as_view(), name='savedfilter_list'),
+    path('saved-filters/add/', views.SavedFilterEditView.as_view(), name='savedfilter_add'),
+    path('saved-filters/import/', views.SavedFilterBulkImportView.as_view(), name='savedfilter_import'),
+    path('saved-filters/edit/', views.SavedFilterBulkEditView.as_view(), name='savedfilter_bulk_edit'),
+    path('saved-filters/delete/', views.SavedFilterBulkDeleteView.as_view(), name='savedfilter_bulk_delete'),
+    path('saved-filters/<int:pk>/', include(get_model_urls('extras', 'savedfilter'))),
+
     # Webhooks
     # Webhooks
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
     path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),
     path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

+ 68 - 1
netbox/extras/views.py

@@ -9,7 +9,6 @@ from django_rq.queues import get_connection
 from rq import Worker
 from rq import Worker
 
 
 from netbox.views import generic
 from netbox.views import generic
-from utilities.forms import ConfirmationForm
 from utilities.htmx import is_htmx
 from utilities.htmx import is_htmx
 from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
@@ -159,6 +158,74 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
     table = tables.ExportTemplateTable
     table = tables.ExportTemplateTable
 
 
 
 
+#
+# Saved filters
+#
+
+class SavedFilterMixin:
+
+    def get_queryset(self, request):
+        """
+        Return only shared SavedFilters, or those owned by the current user, unless
+        this is a superuser.
+        """
+        queryset = SavedFilter.objects.all()
+        user = request.user
+        if user.is_superuser:
+            return queryset
+        if user.is_anonymous:
+            return queryset.filter(shared=True)
+        return queryset.filter(
+            Q(shared=True) | Q(user=user)
+        )
+
+
+class SavedFilterListView(SavedFilterMixin, generic.ObjectListView):
+    filterset = filtersets.SavedFilterFilterSet
+    filterset_form = forms.SavedFilterFilterForm
+    table = tables.SavedFilterTable
+
+
+@register_model_view(SavedFilter)
+class SavedFilterView(SavedFilterMixin, generic.ObjectView):
+    queryset = SavedFilter.objects.all()
+
+
+@register_model_view(SavedFilter, 'edit')
+class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView):
+    queryset = SavedFilter.objects.all()
+    form = forms.SavedFilterForm
+
+    def alter_object(self, obj, request, url_args, url_kwargs):
+        if not obj.pk:
+            obj.user = request.user
+        return obj
+
+
+@register_model_view(SavedFilter, 'delete')
+class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView):
+    queryset = SavedFilter.objects.all()
+
+
+class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView):
+    queryset = SavedFilter.objects.all()
+    model_form = forms.SavedFilterCSVForm
+    table = tables.SavedFilterTable
+
+
+class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView):
+    queryset = SavedFilter.objects.all()
+    filterset = filtersets.SavedFilterFilterSet
+    table = tables.SavedFilterTable
+    form = forms.SavedFilterBulkEditForm
+
+
+class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
+    queryset = SavedFilter.objects.all()
+    filterset = filtersets.SavedFilterFilterSet
+    table = tables.SavedFilterTable
+
+
 #
 #
 # Webhooks
 # Webhooks
 #
 #

+ 17 - 16
netbox/ipam/forms/filtersets.py

@@ -1,6 +1,5 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
 from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
@@ -11,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple,
+    MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
@@ -46,7 +45,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VRF
     model = VRF
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Route Targets', ('import_target_id', 'export_target_id')),
         ('Route Targets', ('import_target_id', 'export_target_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -66,7 +65,7 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RouteTarget
     model = RouteTarget
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('VRF', ('importing_vrf_id', 'exporting_vrf_id')),
         ('VRF', ('importing_vrf_id', 'exporting_vrf_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -98,7 +97,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
 class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Aggregate
     model = Aggregate
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('family', 'rir_id')),
         ('Attributes', ('family', 'rir_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -119,7 +118,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = ASN
     model = ASN
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Assignment', ('rir_id', 'site_id')),
         ('Assignment', ('rir_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -144,7 +143,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
 class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Prefix
     model = Prefix
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')),
         ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
@@ -233,7 +232,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPRange
     model = IPRange
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')),
         ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -265,7 +264,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPAddress
     model = IPAddress
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
         ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -334,7 +333,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
 class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
     model = FHRPGroup
     model = FHRPGroup
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'protocol', 'group_id')),
         ('Attributes', ('name', 'protocol', 'group_id')),
         ('Authentication', ('auth_type', 'auth_key')),
         ('Authentication', ('auth_type', 'auth_key')),
     )
     )
@@ -364,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
 
 
 class VLANGroupFilterForm(NetBoxModelFilterSetForm):
 class VLANGroupFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')),
         ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')),
         ('VLAN ID', ('min_vid', 'max_vid')),
         ('VLAN ID', ('min_vid', 'max_vid')),
     )
     )
@@ -412,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
 class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VLAN
     model = VLAN
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Attributes', ('group_id', 'status', 'role_id', 'vid')),
         ('Attributes', ('group_id', 'status', 'role_id', 'vid')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -465,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
     model = ServiceTemplate
     model = ServiceTemplate
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('protocol', 'port')),
         ('Attributes', ('protocol', 'port')),
     )
     )
     protocol = forms.ChoiceField(
     protocol = forms.ChoiceField(
@@ -486,7 +485,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm):
 class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = L2VPN
     model = L2VPN
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('type', 'import_target_id', 'export_target_id')),
         ('Attributes', ('type', 'import_target_id', 'export_target_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
@@ -511,8 +510,10 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
 class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
     model = L2VPNTermination
     model = L2VPNTermination
     fieldsets = (
     fieldsets = (
-        (None, ('l2vpn_id', )),
-        ('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')),
+        (None, ('filter', 'l2vpn_id',)),
+        ('Assigned Object', (
+            'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
+        )),
     )
     )
     l2vpn_id = DynamicModelChoiceField(
     l2vpn_id = DynamicModelChoiceField(
         queryset=L2VPN.objects.all(),
         queryset=L2VPN.objects.all(),

+ 20 - 3
netbox/netbox/filtersets.py

@@ -4,10 +4,11 @@ from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db import models
 from django_filters.exceptions import FieldLookupError
 from django_filters.exceptions import FieldLookupError
 from django_filters.utils import get_model_field, resolve_field
 from django_filters.utils import get_model_field, resolve_field
+from django.shortcuts import get_object_or_404
 
 
 from extras.choices import CustomFieldFilterLogicChoices
 from extras.choices import CustomFieldFilterLogicChoices
 from extras.filters import TagFilter
 from extras.filters import TagFilter
-from extras.models import CustomField
+from extras.models import CustomField, SavedFilter
 from utilities.constants import (
 from utilities.constants import (
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
     FILTER_NUMERIC_BASED_LOOKUP_MAP
     FILTER_NUMERIC_BASED_LOOKUP_MAP
@@ -80,12 +81,28 @@ class BaseFilterSet(django_filters.FilterSet):
         },
         },
     })
     })
 
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, data=None, *args, **kwargs):
         # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
         # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
         # however FilterSet Factory is setup before this which creates the
         # however FilterSet Factory is setup before this which creates the
         # initial filters.  This recreates the filters so Empty is picked up correctly.
         # initial filters.  This recreates the filters so Empty is picked up correctly.
         self.base_filters = self.__class__.get_filters()
         self.base_filters = self.__class__.get_filters()
-        super().__init__(*args, **kwargs)
+
+        # Apply any referenced SavedFilters
+        if data and 'filter' in data:
+            data = data.copy()  # Get a mutable copy
+            saved_filters = SavedFilter.objects.filter(pk__in=data.pop('filter'))
+            for sf in saved_filters:
+                for key, value in sf.parameters.items():
+                    # QueryDicts are... fun
+                    if type(value) not in (list, tuple):
+                        value = [value]
+                    if key in data:
+                        for v in value:
+                            data.appendlist(key, v)
+                    else:
+                        data.setlist(key, value)
+
+        super().__init__(data, *args, **kwargs)
 
 
     @staticmethod
     @staticmethod
     def _get_filter_lookup_dict(existing_filter):
     def _get_filter_lookup_dict(existing_filter):

+ 11 - 2
netbox/netbox/forms/base.py

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.db.models import Q
 
 
 from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
 from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
-from extras.forms.customfields import CustomFieldsMixin
+from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin
 from extras.models import CustomField, Tag
 from extras.models import CustomField, Tag
 from utilities.forms import BootstrapMixin, CSVModelForm
 from utilities.forms import BootstrapMixin, CSVModelForm
 from utilities.forms.fields import DynamicModelMultipleChoiceField
 from utilities.forms.fields import DynamicModelMultipleChoiceField
@@ -114,7 +114,7 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
         self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
         self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields)
 
 
 
 
-class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
+class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form):
     """
     """
     Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the
     Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the
     corresponding FilterSet *must* provide a `q` filter.
     corresponding FilterSet *must* provide a `q` filter.
@@ -129,6 +129,15 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
         label='Search'
         label='Search'
     )
     )
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit saved filters to those applicable to the form's model
+        content_type = ContentType.objects.get_for_model(self.model)
+        self.fields['filter'].widget.add_query_params({
+            'content_type_id': content_type.pk,
+        })
+
     def _get_custom_fields(self, content_type):
     def _get_custom_fields(self, content_type):
         return super()._get_custom_fields(content_type).exclude(
         return super()._get_custom_fields(content_type).exclude(
             Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
             Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -278,6 +278,7 @@ OTHER_MENU = Menu(
                 get_model_item('extras', 'customfield', 'Custom Fields'),
                 get_model_item('extras', 'customfield', 'Custom Fields'),
                 get_model_item('extras', 'customlink', 'Custom Links'),
                 get_model_item('extras', 'customlink', 'Custom Links'),
                 get_model_item('extras', 'exporttemplate', 'Export Templates'),
                 get_model_item('extras', 'exporttemplate', 'Export Templates'),
+                get_model_item('extras', 'savedfilter', 'Saved Filters'),
             ),
             ),
         ),
         ),
         MenuGroup(
         MenuGroup(

+ 4 - 5
netbox/netbox/views/generic/bulk_views.py

@@ -4,17 +4,17 @@ from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldDoesNotExist, ValidationError, ObjectDoesNotExist
+from django.core.exceptions import FieldDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError
 from django.db.models import ManyToManyField, ProtectedError
 from django.db.models.fields.reverse_related import ManyToManyRel
 from django.db.models.fields.reverse_related import ManyToManyRel
-from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, model_to_dict
+from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse
 from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
-from django_tables2.export import TableExport
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
+from django_tables2.export import TableExport
 
 
-from extras.models import ExportTemplate
+from extras.models import ExportTemplate, SavedFilter
 from extras.signals import clear_webhooks
 from extras.signals import clear_webhooks
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, PermissionsViolation
 from utilities.exceptions import AbortRequest, PermissionsViolation
@@ -330,7 +330,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
         return headers, records
         return headers, records
 
 
     def _update_objects(self, form, request, headers, records):
     def _update_objects(self, form, request, headers, records):
-        from utilities.forms import CSVModelChoiceField
         updated_objs = []
         updated_objs = []
 
 
         ids = [int(record["id"]) for record in records]
         ids = [int(record["id"]) for record in records]

+ 70 - 0
netbox/templates/extras/savedfilter.html

@@ -0,0 +1,70 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+<div class="row mb-3">
+	<div class="col col-md-5">
+    <div class="card">
+      <h5 class="card-header">Saved Filter</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Name</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+          </tr>
+          <tr>
+              <th scope="row">User</th>
+              <td>{{ object.user|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Enabled</th>
+            <td>{% checkmark object.enabled %}</td>
+          </tr>
+          <tr>
+            <th scope="row">Shared</th>
+            <td>{% checkmark object.shared %}</td>
+          </tr>
+          <tr>
+            <th scope="row">Weight</th>
+            <td>{{ object.weight }}</td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    <div class="card">
+      <h5 class="card-header">Assigned Models</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          {% for ct in object.content_types.all %}
+            <tr>
+              <td>{{ ct }}</td>
+            </tr>
+          {% endfor %}
+        </table>
+      </div>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-7">
+    <div class="card">
+      <h5 class="card-header">
+        Parameters
+      </h5>
+      <div class="card-body">
+        <pre>{{ object.parameters }}</pre>
+      </div>
+    </div>
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+    <div class="col col-md-12">
+        {% plugin_full_width_page object %}
+    </div>
+</div>
+{% endblock %}

+ 1 - 1
netbox/templates/generic/object_list.html

@@ -64,7 +64,7 @@ Context:
 
 
       {# Applied filters #}
       {# Applied filters #}
       {% if filter_form %}
       {% if filter_form %}
-        {% applied_filters filter_form request.GET %}
+        {% applied_filters model filter_form request.GET %}
       {% endif %}
       {% endif %}
 
 
       {# "Select all" form #}
       {# "Select all" form #}

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

@@ -31,7 +31,7 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm):
 class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Tenant
     model = Tenant
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag', 'group_id')),
+        (None, ('q', 'filter', 'tag', 'group_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     )
     group_id = DynamicModelMultipleChoiceField(
     group_id = DynamicModelMultipleChoiceField(

+ 5 - 0
netbox/utilities/templates/helpers/applied_filters.html

@@ -10,5 +10,10 @@
         <i class="mdi mdi-tag-off"></i> Clear all
         <i class="mdi mdi-tag-off"></i> Clear all
       </a>
       </a>
     {% endif %}
     {% endif %}
+    {% if save_link %}
+      <a href="{{ save_link }}" class="badge rounded-pill bg-success text-decoration-none me-1">
+        <i class="mdi mdi-content-save"></i> Save
+      </a>
+    {% endif %}
   </div>
   </div>
 {% endif %}
 {% endif %}

+ 14 - 3
netbox/utilities/templatetags/helpers.py

@@ -1,9 +1,11 @@
 import datetime
 import datetime
 import decimal
 import decimal
+from urllib.parse import quote
 from typing import Dict, Any
 from typing import Dict, Any
 
 
 from django import template
 from django import template
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.template.defaultfilters import date
 from django.template.defaultfilters import date
 from django.urls import NoReverseMatch, reverse
 from django.urls import NoReverseMatch, reverse
 from django.utils import timezone
 from django.utils import timezone
@@ -278,12 +280,13 @@ def table_config_form(table, table_name=None):
     }
     }
 
 
 
 
-@register.inclusion_tag('helpers/applied_filters.html')
-def applied_filters(form, query_params):
+@register.inclusion_tag('helpers/applied_filters.html', takes_context=True)
+def applied_filters(context, model, form, query_params):
     """
     """
     Display the active filters for a given filter form.
     Display the active filters for a given filter form.
     """
     """
-    form.is_valid()
+    user = context['request'].user
+    form.is_valid()  # Ensure cleaned_data has been set
 
 
     applied_filters = []
     applied_filters = []
     for filter_name in form.changed_data:
     for filter_name in form.changed_data:
@@ -305,6 +308,14 @@ def applied_filters(form, query_params):
             'link_text': f'{bound_field.label}: {display_value}',
             'link_text': f'{bound_field.label}: {display_value}',
         })
         })
 
 
+    save_link = None
+    if user.has_perm('extras.add_savedfilter') and 'filter' not in context['request'].GET:
+        content_type = ContentType.objects.get_for_model(model).pk
+        parameters = context['request'].GET.urlencode()
+        url = reverse('extras:savedfilter_add')
+        save_link = f"{url}?content_types={content_type}&parameters={quote(parameters)}"
+
     return {
     return {
         'applied_filters': applied_filters,
         'applied_filters': applied_filters,
+        'save_link': save_link,
     }
     }

+ 7 - 1
netbox/utilities/testing/base.py

@@ -1,8 +1,10 @@
+import json
+
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
-from django.db.models import ManyToManyField
+from django.db.models import ManyToManyField, JSONField
 from django.forms.models import model_to_dict
 from django.forms.models import model_to_dict
 from django.test import Client, TestCase as _TestCase
 from django.test import Client, TestCase as _TestCase
 from netaddr import IPNetwork
 from netaddr import IPNetwork
@@ -132,6 +134,10 @@ class ModelTestCase(TestCase):
                 if type(instance._meta.get_field(key)) is ArrayField:
                 if type(instance._meta.get_field(key)) is ArrayField:
                     model_dict[key] = ','.join([str(v) for v in value])
                     model_dict[key] = ','.join([str(v) for v in value])
 
 
+                # JSON
+                if type(instance._meta.get_field(key)) is JSONField and value is not None:
+                    model_dict[key] = json.dumps(value)
+
         return model_dict
         return model_dict
 
 
     #
     #

+ 4 - 4
netbox/virtualization/forms/filtersets.py

@@ -30,7 +30,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = ClusterGroup
     model = ClusterGroup
     tag = TagFilterField(model)
     tag = TagFilterField(model)
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
     )
 
 
@@ -38,7 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Cluster
     model = Cluster
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('group_id', 'type_id', 'status')),
         ('Attributes', ('group_id', 'type_id', 'status')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -90,7 +90,7 @@ class VirtualMachineFilterForm(
 ):
 ):
     model = VirtualMachine
     model = VirtualMachine
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
         ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
         ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
@@ -175,7 +175,7 @@ class VirtualMachineFilterForm(
 class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
 class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
     model = VMInterface
     model = VMInterface
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
         ('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
         ('Attributes', ('enabled', 'mac_address', 'vrf_id')),
         ('Attributes', ('enabled', 'mac_address', 'vrf_id')),
     )
     )

+ 2 - 2
netbox/wireless/forms/filtersets.py

@@ -28,7 +28,7 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm):
 class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = WirelessLAN
     model = WirelessLAN
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('ssid', 'group_id',)),
         ('Attributes', ('ssid', 'group_id',)),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
@@ -62,7 +62,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = WirelessLink
     model = WirelessLink
     fieldsets = (
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('ssid', 'status',)),
         ('Attributes', ('ssid', 'status',)),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),