Initial work on saved filters

This commit is contained in:
jeremystretch 2022-10-27 14:24:57 -04:00
parent ea61a540cd
commit 57009643fb
26 changed files with 486 additions and 68 deletions

View File

@ -144,7 +144,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Site model = Site
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'filter', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),

View File

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

View File

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

View File

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

View File

@ -98,6 +98,17 @@ class ExportTemplateViewSet(NetBoxModelViewSet):
filterset_class = filtersets.ExportTemplateFilterSet filterset_class = filtersets.ExportTemplateFilterSet
#
# Saved filters
#
class SavedFilterViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = SavedFilter.objects.all()
serializer_class = serializers.SavedFilterSerializer
filterset_class = filtersets.SavedFilterFilterSet
# #
# Tags # Tags
# #

View File

@ -23,6 +23,7 @@ __all__ = (
'JournalEntryFilterSet', 'JournalEntryFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'SavedFilterFilterSet',
'TagFilterSet', 'TagFilterSet',
'WebhookFilterSet', 'WebhookFilterSet',
) )
@ -138,6 +139,29 @@ 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()
class Meta:
model = SavedFilter
fields = ['id', 'content_types', 'name', 'description', 'user', '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)
)
class ImageAttachmentFilterSet(BaseFilterSet): class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

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

View File

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

View File

@ -25,6 +25,7 @@ __all__ = (
'JournalEntryFilterForm', 'JournalEntryFilterForm',
'LocalConfigContextFilterForm', 'LocalConfigContextFilterForm',
'ObjectChangeFilterForm', 'ObjectChangeFilterForm',
'SavedFilterFilterForm',
'TagFilterForm', 'TagFilterForm',
'WebhookFilterForm', 'WebhookFilterForm',
) )
@ -170,6 +171,33 @@ class ExportTemplateFilterForm(FilterForm):
) )
class SavedFilterFilterForm(FilterForm):
fieldsets = (
(None, ('q',)),
('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(FilterForm): class WebhookFilterForm(FilterForm):
fieldsets = ( fieldsets = (
(None, ('q',)), (None, ('q',)),

View File

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

View File

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

View File

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

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', 'JournalEntry',
'ObjectChange', 'ObjectChange',
'Report', 'Report',
'SavedFilter',
'Script', 'Script',
'Tag', 'Tag',
'TaggedItem', 'TaggedItem',

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q from django.db.models import Count
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@ -9,7 +9,6 @@ from django_rq.queues import get_connection
from rq import Worker from rq import Worker
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
@ -159,6 +158,57 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
table = tables.ExportTemplateTable table = tables.ExportTemplateTable
#
# Saved filters
#
class SavedFilterListView(generic.ObjectListView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
filterset_form = forms.SavedFilterFilterForm
table = tables.SavedFilterTable
@register_model_view(SavedFilter)
class SavedFilterView(generic.ObjectView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'edit')
class SavedFilterEditView(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(generic.ObjectDeleteView):
queryset = SavedFilter.objects.all()
class SavedFilterBulkImportView(generic.BulkImportView):
queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterCSVForm
table = tables.SavedFilterTable
class SavedFilterBulkEditView(generic.BulkEditView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable
form = forms.SavedFilterBulkEditForm
class SavedFilterBulkDeleteView(generic.BulkDeleteView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable
# #
# Webhooks # Webhooks
# #

View File

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

View File

@ -4,7 +4,7 @@ from django.db.models import Q
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
from extras.forms.customfields import CustomFieldsMixin from extras.forms.customfields import CustomFieldsMixin
from extras.models import CustomField, Tag from extras.models import CustomField, SavedFilter, Tag
from utilities.forms import BootstrapMixin, CSVModelForm from utilities.forms import BootstrapMixin, CSVModelForm
from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
@ -128,6 +128,19 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
required=False, required=False,
label='Search' label='Search'
) )
filter = DynamicModelMultipleChoiceField(
queryset=SavedFilter.objects.all(),
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit saved filters to those applicable to the form's model
content_type = ContentType.objects.get_for_model(self.model)
self.fields['filter'].widget.add_query_params({
'content_type_id': content_type.pk,
})
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):
return super()._get_custom_fields(content_type).exclude( return super()._get_custom_fields(content_type).exclude(

View File

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

View File

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

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.enabled %}</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 #} {# Applied filters #}
{% if filter_form %} {% if filter_form %}
{% applied_filters filter_form request.GET %} {% applied_filters model filter_form request.GET %}
{% endif %} {% endif %}
{# "Select all" form #} {# "Select all" form #}

View File

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

View File

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