瀏覽代碼

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 3 年之前
父節點
當前提交
484efdaf75
共有 37 個文件被更改,包括 821 次插入138 次删除
  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):
     model = Provider
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('ASN', ('asn',)),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
     model = ProviderNetwork
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('provider_id', 'service_id')),
     )
     provider_id = DynamicModelMultipleChoiceField(
@@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
 class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Circuit
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Provider', ('provider_id', 'provider_network_id')),
         ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
         ('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):
     model = Region
     fieldsets = (
-        (None, ('q', 'tag', 'parent_id')),
+        (None, ('q', 'filter', 'tag', 'parent_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     parent_id = DynamicModelMultipleChoiceField(
@@ -130,7 +130,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = SiteGroup
     fieldsets = (
-        (None, ('q', 'tag', 'parent_id')),
+        (None, ('q', 'filter', 'tag', 'parent_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     parent_id = DynamicModelMultipleChoiceField(
@@ -144,7 +144,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Site
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -174,7 +174,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
 class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Location
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -222,7 +222,7 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
 class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Rack
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Function', ('status', 'role_id')),
         ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
@@ -306,7 +306,7 @@ class RackElevationFilterForm(RackFilterForm):
 class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RackReservation
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('User', ('user_id',)),
         ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -362,7 +362,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Manufacturer
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     tag = TagFilterField(model)
@@ -371,7 +371,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
     model = DeviceType
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
         ('Images', ('has_front_image', 'has_rear_image')),
         ('Components', (
@@ -486,7 +486,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
 class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     model = ModuleType
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Hardware', ('manufacturer_id', 'part_number')),
         ('Components', (
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
@@ -578,7 +578,7 @@ class DeviceFilterForm(
 ):
     model = Device
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
         ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
         ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
@@ -731,7 +731,7 @@ class DeviceFilterForm(
 class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Module
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
@@ -761,7 +761,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
 class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VirtualChassis
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -790,7 +790,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Cable
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
         ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -862,7 +862,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = PowerPanel
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
@@ -900,7 +900,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class PowerFeedFilterForm(NetBoxModelFilterSetForm):
     model = PowerFeed
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
         ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
     )
@@ -1002,7 +1002,7 @@ class PathEndpointFilterForm(CabledFilterForm):
 class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsolePort
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1021,7 +1021,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = ConsoleServerPort
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'speed')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1040,7 +1040,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
 class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerPort
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1055,7 +1055,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = PowerOutlet
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Connection', ('cabled', 'connected', 'occupied')),
@@ -1070,7 +1070,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
         ('Addressing', ('vrf_id', 'mac_address', 'wwn')),
         ('PoE', ('poe_mode', 'poe_type')),
@@ -1159,7 +1159,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
 
 class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Cable', ('cabled', 'occupied')),
@@ -1178,7 +1178,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
     model = RearPort
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'type', 'color')),
         ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
         ('Cable', ('cabled', 'occupied')),
@@ -1196,7 +1196,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
 class ModuleBayFilterForm(DeviceComponentFilterForm):
     model = ModuleBay
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label', 'position')),
         ('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):
     model = DeviceBay
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'label')),
         ('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):
     model = InventoryItem
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('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')),
     )

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

@@ -13,6 +13,7 @@ __all__ = [
     'NestedImageAttachmentSerializer',
     'NestedJobResultSerializer',
     'NestedJournalEntrySerializer',
+    'NestedSavedFilterSerializer',
     'NestedTagSerializer',  # Defined in netbox.api.serializers
     'NestedWebhookSerializer',
 ]
@@ -58,6 +59,14 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
         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):
     url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
 

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

@@ -39,6 +39,7 @@ __all__ = (
     'ReportDetailSerializer',
     'ReportSerializer',
     'ReportInputSerializer',
+    'SavedFilterSerializer',
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
     '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
 #

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

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

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

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
 from django.http import Http404
 from django_rq.queues import get_connection
 from rest_framework import status
@@ -98,6 +99,17 @@ class ExportTemplateViewSet(NetBoxModelViewSet):
     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
 #

+ 50 - 0
netbox/extras/filtersets.py

@@ -23,6 +23,7 @@ __all__ = (
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
+    'SavedFilterFilterSet',
     'TagFilterSet',
     '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):
     q = django_filters.CharFilter(
         method='search',

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

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

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

@@ -1,11 +1,9 @@
 from django import forms
-from django.contrib.contenttypes.models import ContentType
 
 from extras.choices import *
 from extras.models import *
-from extras.utils import FeatureQuery
 from utilities.forms import (
-    add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
+    add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect,
 )
 
 __all__ = (
@@ -14,6 +12,7 @@ __all__ = (
     'CustomLinkBulkEditForm',
     'ExportTemplateBulkEditForm',
     'JournalEntryBulkEditForm',
+    'SavedFilterBulkEditForm',
     'TagBulkEditForm',
     'WebhookBulkEditForm',
 )
@@ -96,6 +95,30 @@ class ExportTemplateBulkEditForm(BulkEditForm):
     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):
     pk = forms.ModelMultipleChoiceField(
         queryset=Webhook.objects.all(),

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

@@ -12,6 +12,7 @@ __all__ = (
     'CustomFieldCSVForm',
     'CustomLinkCSVForm',
     'ExportTemplateCSVForm',
+    'SavedFilterCSVForm',
     'TagCSVForm',
     '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):
     content_types = CSVMultipleContentTypeField(
         queryset=ContentType.objects.all(),

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

@@ -15,6 +15,7 @@ from utilities.forms import (
     StaticSelect, TagFilterField,
 )
 from virtualization.models import Cluster, ClusterGroup, ClusterType
+from .mixins import SavedFiltersMixin
 
 __all__ = (
     'ConfigContextFilterForm',
@@ -25,14 +26,15 @@ __all__ = (
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
     'ObjectChangeFilterForm',
+    'SavedFilterFilterForm',
     'TagFilterForm',
     'WebhookFilterForm',
 )
 
 
-class CustomFieldFilterForm(FilterForm):
+class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
     )
     content_type_id = ContentTypeMultipleChoiceField(
@@ -66,9 +68,9 @@ class CustomFieldFilterForm(FilterForm):
     )
 
 
-class JobResultFilterForm(FilterForm):
+class JobResultFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('obj_type', 'status')),
         ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
                       'scheduled_time__before', 'scheduled_time__after', 'user')),
@@ -118,9 +120,9 @@ class JobResultFilterForm(FilterForm):
     )
 
 
-class CustomLinkFilterForm(FilterForm):
+class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
     )
     content_types = ContentTypeMultipleChoiceField(
@@ -145,9 +147,9 @@ class CustomLinkFilterForm(FilterForm):
     )
 
 
-class ExportTemplateFilterForm(FilterForm):
+class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
     )
     content_types = ContentTypeMultipleChoiceField(
@@ -170,9 +172,36 @@ class ExportTemplateFilterForm(FilterForm):
     )
 
 
-class WebhookFilterForm(FilterForm):
+class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
     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')),
         ('Events', ('type_create', 'type_update', 'type_delete')),
     )
@@ -213,7 +242,7 @@ class WebhookFilterForm(FilterForm):
     )
 
 
-class TagFilterForm(FilterForm):
+class TagFilterForm(SavedFiltersMixin, FilterForm):
     model = Tag
     content_type_id = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
@@ -222,9 +251,9 @@ class TagFilterForm(FilterForm):
     )
 
 
-class ConfigContextFilterForm(FilterForm):
+class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
-        (None, ('q', 'tag_id')),
+        (None, ('q', 'filter', 'tag_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Device', ('device_type_id', 'platform_id', 'role_id')),
         ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
@@ -311,7 +340,7 @@ class LocalConfigContextFilterForm(forms.Form):
 class JournalEntryFilterForm(NetBoxModelFilterSetForm):
     model = JournalEntry
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Creation', ('created_before', 'created_after', 'created_by_id')),
         ('Attributes', ('assigned_object_type_id', 'kind'))
     )
@@ -349,10 +378,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
     tag = TagFilterField(model)
 
 
-class ObjectChangeFilterForm(FilterForm):
+class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
     model = ObjectChange
     fieldsets = (
-        (None, ('q',)),
+        (None, ('q', 'filter')),
         ('Time', ('time_before', 'time_after')),
         ('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 import forms
 
 from extras.models import *
 from extras.choices import CustomFieldVisibilityChoices
+from utilities.forms.fields import DynamicModelMultipleChoiceField
 
 __all__ = (
     'CustomFieldsMixin',
+    'SavedFiltersMixin',
 )
 
 
@@ -57,3 +60,14 @@ class CustomFieldsMixin:
             if customfield.group_name not in self.custom_field_groups:
                 self.custom_field_groups[customfield.group_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.contrib.contenttypes.models import ContentType
+from django.http import QueryDict
 
 from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
@@ -20,6 +21,7 @@ __all__ = (
     'ExportTemplateForm',
     'ImageAttachmentForm',
     'JournalEntryForm',
+    'SavedFilterForm',
     'TagForm',
     '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):
     content_types = ContentTypeMultipleChoiceField(
         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_list = ObjectListField(ImageAttachmentType)
 
+    saved_filter = ObjectField(SavedFilterType)
+    saved_filter_list = ObjectListField(SavedFilterType)
+
     journal_entry = ObjectField(JournalEntryType)
     journal_entry_list = ObjectListField(JournalEntryType)
 

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

@@ -10,6 +10,7 @@ __all__ = (
     'ImageAttachmentType',
     'JournalEntryType',
     'ObjectChangeType',
+    'SavedFilterType',
     'TagType',
     'WebhookType',
 )
@@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType):
         filterset_class = filtersets.ObjectChangeFilterSet
 
 
+class SavedFilterType(ObjectType):
+
+    class Meta:
+        model = models.SavedFilter
+        exclude = ('content_types', )
+        filterset_class = filtersets.SavedFilterFilterSet
+
+
 class TagType(ObjectType):
 
     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',
     'ObjectChange',
     'Report',
+    'SavedFilter',
     'Script',
     'Tag',
     '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.validators import ValidationError
 from django.db import models
-from django.http import HttpResponse
+from django.http import HttpResponse, QueryDict
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.formats import date_format
@@ -34,6 +34,7 @@ __all__ = (
     'JobResult',
     'JournalEntry',
     'Report',
+    'SavedFilter',
     'Script',
     'Webhook',
 )
@@ -350,6 +351,69 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
         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):
     """
     An uploaded image which is associated with an object.

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

@@ -13,16 +13,13 @@ __all__ = (
     'ExportTemplateTable',
     'JournalEntryTable',
     'ObjectChangeTable',
+    'SavedFilterTable',
     'TaggedItemTable',
     'TagTable',
     'WebhookTable',
 )
 
 
-#
-# Custom fields
-#
-
 class CustomFieldTable(NetBoxTable):
     name = tables.Column(
         linkify=True
@@ -40,10 +37,6 @@ class CustomFieldTable(NetBoxTable):
         default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
 
 
-#
-# Custom fields
-#
-
 class JobResultTable(NetBoxTable):
     name = tables.Column(
         linkify=True
@@ -61,10 +54,6 @@ class JobResultTable(NetBoxTable):
         default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',)
 
 
-#
-# Custom links
-#
-
 class CustomLinkTable(NetBoxTable):
     name = tables.Column(
         linkify=True
@@ -82,10 +71,6 @@ class CustomLinkTable(NetBoxTable):
         default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window')
 
 
-#
-# Export templates
-#
-
 class ExportTemplateTable(NetBoxTable):
     name = tables.Column(
         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):
     name = tables.Column(
@@ -139,10 +139,6 @@ class WebhookTable(NetBoxTable):
         )
 
 
-#
-# Tags
-#
-
 class TagTable(NetBoxTable):
     name = tables.Column(
         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.contenttypes.models import ContentType
-from django.test import override_settings
 from django.urls import reverse
 from django.utils.timezone import make_aware
 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 utilities.testing import APITestCase, APIViewTestCases
 
-
 rq_worker_running = Worker.count(get_connection('default'))
 
 
@@ -192,6 +190,73 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
             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):
     model = ExportTemplate
     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)
 
 
+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):
     queryset = ExportTemplate.objects.all()
     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):
     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/<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
     path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
     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 netbox.views import generic
-from utilities.forms import ConfirmationForm
 from utilities.htmx import is_htmx
 from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
@@ -159,6 +158,74 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
     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
 #

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

@@ -1,6 +1,5 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
 from django.utils.translation import gettext as _
 
 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 utilities.forms import (
     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
 
@@ -46,7 +45,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VRF
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Route Targets', ('import_target_id', 'export_target_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -66,7 +65,7 @@ class VRFFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class RouteTargetFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = RouteTarget
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('VRF', ('importing_vrf_id', 'exporting_vrf_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -98,7 +97,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
 class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Aggregate
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('family', 'rir_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -119,7 +118,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = ASN
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Assignment', ('rir_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -144,7 +143,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
 class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Prefix
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Addressing', ('within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
@@ -233,7 +232,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPRange
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attriubtes', ('family', 'vrf_id', 'status', 'role_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -265,7 +264,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = IPAddress
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
         ('VRF', ('vrf_id', 'present_in_vrf_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -334,7 +333,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
     model = FHRPGroup
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('name', 'protocol', 'group_id')),
         ('Authentication', ('auth_type', 'auth_key')),
     )
@@ -364,7 +363,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
 
 class VLANGroupFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region', 'sitegroup', 'site', 'location', 'rack')),
         ('VLAN ID', ('min_vid', 'max_vid')),
     )
@@ -412,7 +411,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
 class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = VLAN
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Attributes', ('group_id', 'status', 'role_id', 'vid')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -465,7 +464,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
     model = ServiceTemplate
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('protocol', 'port')),
     )
     protocol = forms.ChoiceField(
@@ -486,7 +485,7 @@ class ServiceFilterForm(ServiceTemplateFilterForm):
 class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = L2VPN
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('type', 'import_target_id', 'export_target_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
@@ -511,8 +510,10 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
     model = L2VPNTermination
     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(
         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_filters.exceptions import FieldLookupError
 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.filters import TagFilter
-from extras.models import CustomField
+from extras.models import CustomField, SavedFilter
 from utilities.constants import (
     FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_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
         # however FilterSet Factory is setup before this which creates the
         # initial filters.  This recreates the filters so Empty is picked up correctly.
         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
     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 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 utilities.forms import BootstrapMixin, CSVModelForm
 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)
 
 
-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
     corresponding FilterSet *must* provide a `q` filter.
@@ -129,6 +129,15 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
         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):
         return super()._get_custom_fields(content_type).exclude(
             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', 'customlink', 'Custom Links'),
                 get_model_item('extras', 'exporttemplate', 'Export Templates'),
+                get_model_item('extras', 'savedfilter', 'Saved Filters'),
             ),
         ),
         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.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.models import ManyToManyField, ProtectedError
 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.shortcuts import get_object_or_404, redirect, render
-from django_tables2.export import TableExport
 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 utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, PermissionsViolation
@@ -330,7 +330,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
         return headers, records
 
     def _update_objects(self, form, request, headers, records):
-        from utilities.forms import CSVModelChoiceField
         updated_objs = []
 
         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 #}
       {% if filter_form %}
-        {% applied_filters filter_form request.GET %}
+        {% applied_filters model filter_form request.GET %}
       {% endif %}
 
       {# "Select all" form #}

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

@@ -31,7 +31,7 @@ class TenantGroupFilterForm(NetBoxModelFilterSetForm):
 class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Tenant
     fieldsets = (
-        (None, ('q', 'tag', 'group_id')),
+        (None, ('q', 'filter', 'tag', 'group_id')),
         ('Contacts', ('contact', 'contact_role', 'contact_group'))
     )
     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
       </a>
     {% 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>
 {% endif %}

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

@@ -1,9 +1,11 @@
 import datetime
 import decimal
+from urllib.parse import quote
 from typing import Dict, Any
 
 from django import template
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.template.defaultfilters import date
 from django.urls import NoReverseMatch, reverse
 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.
     """
-    form.is_valid()
+    user = context['request'].user
+    form.is_valid()  # Ensure cleaned_data has been set
 
     applied_filters = []
     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}',
         })
 
+    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 {
         '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.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 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.test import Client, TestCase as _TestCase
 from netaddr import IPNetwork
@@ -132,6 +134,10 @@ class ModelTestCase(TestCase):
                 if type(instance._meta.get_field(key)) is ArrayField:
                     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
 
     #

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

@@ -30,7 +30,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = ClusterGroup
     tag = TagFilterField(model)
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Contacts', ('contact', 'contact_role', 'contact_group')),
     )
 
@@ -38,7 +38,7 @@ class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
 class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
     model = Cluster
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('group_id', 'type_id', 'status')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -90,7 +90,7 @@ class VirtualMachineFilterForm(
 ):
     model = VirtualMachine
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
@@ -175,7 +175,7 @@ class VirtualMachineFilterForm(
 class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
     model = VMInterface
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Virtual Machine', ('cluster_id', 'virtual_machine_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):
     model = WirelessLAN
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('ssid', 'group_id',)),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
@@ -62,7 +62,7 @@ class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = WirelessLink
     fieldsets = (
-        (None, ('q', 'tag')),
+        (None, ('q', 'filter', 'tag')),
         ('Attributes', ('ssid', 'status',)),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),