1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #9623: Implement saved filters (#10801)

* Initial work on saved filters

* Return only enabled/shared filters

* Add tests

* Clean up filtering of usable SavedFilters
This commit is contained in:
Jeremy Stretch
2022-11-02 12:27:53 -04:00
committed by GitHub
parent ea61a540cd
commit 484efdaf75
37 changed files with 821 additions and 138 deletions

View File

@ -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')),

View File

@ -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')),
)

View File

@ -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')

View File

@ -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
#

View File

@ -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'

View File

@ -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
#

View File

@ -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',

View File

@ -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 *

View File

@ -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(),

View File

@ -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(),

View File

@ -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')),
)

View File

@ -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,
}
)

View File

@ -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(),

View File

@ -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)

View File

@ -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:

View File

@ -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'),
},
),
]

View File

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

View File

@ -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.

View File

@ -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

View File

@ -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']

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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
#

View File

@ -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(),

View File

@ -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):

View File

@ -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) |

View File

@ -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(

View File

@ -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]

View File

@ -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 %}

View File

@ -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 #}

View File

@ -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(

View File

@ -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 %}

View File

@ -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,
}

View File

@ -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
#

View File

@ -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')),
)

View File

@ -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')),