diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 818da83e1..27efe97ff 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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')), diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 44dfe7cbc..dce062b84 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -13,6 +13,7 @@ __all__ = [ 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', 'NestedJournalEntrySerializer', + 'NestedSavedFilterSerializer', 'NestedTagSerializer', # Defined in netbox.api.serializers 'NestedWebhookSerializer', ] @@ -58,6 +59,14 @@ class NestedExportTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedSavedFilterSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + + class Meta: + model = models.SavedFilter + fields = ['id', 'url', 'display', 'name'] + + class NestedImageAttachmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index ac025ff16..1afb8fa8f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -39,6 +39,7 @@ __all__ = ( 'ReportDetailSerializer', 'ReportSerializer', 'ReportInputSerializer', + 'SavedFilterSerializer', 'ScriptDetailSerializer', 'ScriptInputSerializer', 'ScriptLogMessageSerializer', @@ -149,6 +150,25 @@ class ExportTemplateSerializer(ValidatedModelSerializer): ] +# +# Saved filters +# + +class SavedFilterSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:savedfilter-detail') + content_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + + class Meta: + model = SavedFilter + fields = [ + 'id', 'url', 'display', 'content_types', 'name', 'description', 'user', 'weight', + 'enabled', 'shared', 'parameters', 'created', 'last_updated', + ] + + # # Tags # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bcad6b77c..1d8418508 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,43 +5,19 @@ from . import views router = NetBoxRouter() router.APIRootView = views.ExtrasRootView -# Webhooks router.register('webhooks', views.WebhookViewSet) - -# Custom fields router.register('custom-fields', views.CustomFieldViewSet) - -# Custom links router.register('custom-links', views.CustomLinkViewSet) - -# Export templates router.register('export-templates', views.ExportTemplateViewSet) - -# Tags +router.register('saved-filter', 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' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 62a011530..d5463a65c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -98,6 +98,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 # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 22fe6537e..000c4a839 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -23,6 +23,7 @@ __all__ = ( 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', + 'SavedFilterFilterSet', 'TagFilterSet', '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): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index df17324ec..a061d9784 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -1,11 +1,9 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from utilities.forms import ( - add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect, + add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect, ) __all__ = ( @@ -14,6 +12,7 @@ __all__ = ( 'CustomLinkBulkEditForm', 'ExportTemplateBulkEditForm', 'JournalEntryBulkEditForm', + 'SavedFilterBulkEditForm', 'TagBulkEditForm', 'WebhookBulkEditForm', ) @@ -96,6 +95,30 @@ class ExportTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_extension') +class SavedFilterBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SavedFilter.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + weight = forms.IntegerField( + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + shared = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + + nullable_fields = ('description',) + + class WebhookBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Webhook.objects.all(), diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index ee638015b..0f5974698 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -12,6 +12,7 @@ __all__ = ( 'CustomFieldCSVForm', 'CustomLinkCSVForm', 'ExportTemplateCSVForm', + 'SavedFilterCSVForm', 'TagCSVForm', 'WebhookCSVForm', ) @@ -81,6 +82,19 @@ class ExportTemplateCSVForm(CSVModelForm): ) +class SavedFilterCSVForm(CSVModelForm): + content_types = CSVMultipleContentTypeField( + queryset=ContentType.objects.all(), + help_text="One or more assigned object types" + ) + + class Meta: + model = SavedFilter + fields = ( + 'name', 'content_types', 'description', 'weight', 'enabled', 'shared', 'parameters', + ) + + class WebhookCSVForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index a164a3d95..2403bb174 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -25,6 +25,7 @@ __all__ = ( 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', 'ObjectChangeFilterForm', + 'SavedFilterFilterForm', 'TagFilterForm', '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): fieldsets = ( (None, ('q',)), diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 7ff4f3e27..97e80100a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.http import QueryDict from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * @@ -20,6 +21,7 @@ __all__ = ( 'ExportTemplateForm', 'ImageAttachmentForm', 'JournalEntryForm', + 'SavedFilterForm', 'TagForm', 'WebhookForm', ) @@ -108,6 +110,34 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): } +class SavedFilterForm(BootstrapMixin, forms.ModelForm): + content_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all() + ) + + fieldsets = ( + ('Saved Filter', ('name', 'content_types', 'description', 'weight', 'enabled', 'shared')), + ('Parameters', ('parameters',)), + ) + + class Meta: + model = SavedFilter + exclude = ('user',) + widgets = { + 'parameters': forms.Textarea(attrs={'class': 'font-monospace'}), + } + + def __init__(self, *args, initial=None, **kwargs): + + # Convert any parameters delivered via initial data to a dictionary + if initial and 'parameters' in initial: + if type(initial['parameters']) is str: + # TODO: Make a utility function for this + initial['parameters'] = dict(QueryDict(initial['parameters']).lists()) + + super().__init__(*args, initial=initial, **kwargs) + + class WebhookForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 3073976e8..0c3113879 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -20,6 +20,9 @@ class ExtrasQuery(graphene.ObjectType): image_attachment = ObjectField(ImageAttachmentType) image_attachment_list = ObjectListField(ImageAttachmentType) + saved_filter = ObjectField(SavedFilterType) + saved_filter_list = ObjectListField(SavedFilterType) + journal_entry = ObjectField(JournalEntryType) journal_entry_list = ObjectListField(JournalEntryType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 3be7b371e..b5d4dffce 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -10,6 +10,7 @@ __all__ = ( 'ImageAttachmentType', 'JournalEntryType', 'ObjectChangeType', + 'SavedFilterType', 'TagType', 'WebhookType', ) @@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType): filterset_class = filtersets.ObjectChangeFilterSet +class SavedFilterType(ObjectType): + + class Meta: + model = models.SavedFilter + exclude = ('content_types', ) + filterset_class = filtersets.SavedFilterFilterSet + + class TagType(ObjectType): class Meta: diff --git a/netbox/extras/migrations/0083_savedfilter.py b/netbox/extras/migrations/0083_savedfilter.py new file mode 100644 index 000000000..6bae7ccde --- /dev/null +++ b/netbox/extras/migrations/0083_savedfilter.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.1 on 2022-10-27 18:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0082_exporttemplate_content_types'), + ] + + operations = [ + migrations.CreateModel( + name='SavedFilter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('weight', models.PositiveSmallIntegerField(default=100)), + ('enabled', models.BooleanField(default=True)), + ('shared', models.BooleanField(default=True)), + ('parameters', models.JSONField()), + ('content_types', models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('weight', 'name'), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index e3a4be3fe..6d2bf288c 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -18,6 +18,7 @@ __all__ = ( 'JournalEntry', 'ObjectChange', 'Report', + 'SavedFilter', 'Script', 'Tag', 'TaggedItem', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index a8b2f2647..4b4e7c0cf 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models -from django.http import HttpResponse +from django.http import HttpResponse, QueryDict from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format @@ -34,6 +34,7 @@ __all__ = ( 'JobResult', 'JournalEntry', 'Report', + 'SavedFilter', 'Script', 'Webhook', ) @@ -350,6 +351,69 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): return response +class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): + """ + A set of predefined keyword parameters that can be reused to filter for specific objects. + """ + content_types = models.ManyToManyField( + to=ContentType, + related_name='saved_filters', + help_text='The object type(s) to which this filter applies.' + ) + name = models.CharField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + user = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + blank=True, + null=True + ) + weight = models.PositiveSmallIntegerField( + default=100 + ) + enabled = models.BooleanField( + default=True + ) + shared = models.BooleanField( + default=True + ) + parameters = models.JSONField() + + clone_fields = ( + 'enabled', 'weight', + ) + + class Meta: + ordering = ('weight', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:savedfilter', args=[self.pk]) + + def clean(self): + super().clean() + + # Verify that `parameters` is a JSON object + if type(self.parameters) is not dict: + raise ValidationError( + {'parameters': 'Filter parameters must be stored as a dictionary of keyword arguments.'} + ) + + @property + def url_params(self): + qd = QueryDict(mutable=True) + qd.update(self.parameters) + return qd.urlencode() + + class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 4b4acb235..da4241e69 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -13,16 +13,13 @@ __all__ = ( 'ExportTemplateTable', 'JournalEntryTable', 'ObjectChangeTable', + 'SavedFilterTable', 'TaggedItemTable', 'TagTable', 'WebhookTable', ) -# -# Custom fields -# - class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True @@ -40,10 +37,6 @@ class CustomFieldTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') -# -# Custom fields -# - class JobResultTable(NetBoxTable): name = tables.Column( linkify=True @@ -61,10 +54,6 @@ class JobResultTable(NetBoxTable): default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',) -# -# Custom links -# - class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True @@ -82,10 +71,6 @@ class CustomLinkTable(NetBoxTable): default_columns = ('pk', 'name', 'content_types', 'enabled', 'group_name', 'button_class', 'new_window') -# -# Export templates -# - class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True @@ -104,9 +89,24 @@ class ExportTemplateTable(NetBoxTable): ) -# -# Webhooks -# +class SavedFilterTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + content_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + shared = columns.BooleanColumn() + + class Meta(NetBoxTable.Meta): + model = SavedFilter + fields = ( + 'pk', 'id', 'name', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared', + 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared', + ) + class WebhookTable(NetBoxTable): name = tables.Column( @@ -139,10 +139,6 @@ class WebhookTable(NetBoxTable): ) -# -# Tags -# - class TagTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 0640904f2..f41a45f5a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -31,6 +31,14 @@ urlpatterns = [ path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), path('export-templates//', 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//', 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'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index c042c248a..c37a3fce2 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,6 +1,6 @@ from django.contrib import messages 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.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -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,57 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView): 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 # diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 6a8f5d0d3..2a9d3719e 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -4,10 +4,11 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django_filters.exceptions import FieldLookupError from django_filters.utils import get_model_field, resolve_field +from django.shortcuts import get_object_or_404 from extras.choices import CustomFieldFilterLogicChoices from extras.filters import TagFilter -from extras.models import CustomField +from extras.models import CustomField, SavedFilter from utilities.constants import ( FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP @@ -80,12 +81,28 @@ class BaseFilterSet(django_filters.FilterSet): }, }) - def __init__(self, *args, **kwargs): + def __init__(self, data=None, *args, **kwargs): # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready # however FilterSet Factory is setup before this which creates the # initial filters. This recreates the filters so Empty is picked up correctly. self.base_filters = self.__class__.get_filters() - super().__init__(*args, **kwargs) + + # Apply any referenced SavedFilters + if '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): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2cbc67971..70007e0d8 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -4,7 +4,7 @@ from django.db.models import Q from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices 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.fields import DynamicModelMultipleChoiceField @@ -128,6 +128,19 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): required=False, 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): return super()._get_custom_fields(content_type).exclude( diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 65c2ec7fc..68551827c 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -278,6 +278,7 @@ OTHER_MENU = Menu( get_model_item('extras', 'customfield', 'Custom Fields'), get_model_item('extras', 'customlink', 'Custom Links'), get_model_item('extras', 'exporttemplate', 'Export Templates'), + get_model_item('extras', 'savedfilter', 'Saved Filters'), ), ), MenuGroup( diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index df7cfdf67..5ab9e6da0 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -4,17 +4,17 @@ from copy import deepcopy from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist, ValidationError, ObjectDoesNotExist +from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, model_to_dict +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render -from django_tables2.export import TableExport from django.utils.safestring import mark_safe +from django_tables2.export import TableExport -from extras.models import ExportTemplate +from extras.models import ExportTemplate, SavedFilter from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation @@ -330,7 +330,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return headers, records def _update_objects(self, form, request, headers, records): - from utilities.forms import CSVModelChoiceField updated_objs = [] ids = [int(record["id"]) for record in records] diff --git a/netbox/templates/extras/savedfilter.html b/netbox/templates/extras/savedfilter.html new file mode 100644 index 000000000..6e56125d7 --- /dev/null +++ b/netbox/templates/extras/savedfilter.html @@ -0,0 +1,70 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Saved Filter
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
User{{ object.user|placeholder }}
Enabled{% checkmark object.enabled %}
Shared{% checkmark object.enabled %}
Weight{{ object.weight }}
+
+
+
+
Assigned Models
+
+ + {% for ct in object.content_types.all %} + + + + {% endfor %} +
{{ ct }}
+
+
+ {% plugin_left_page object %} +
+
+
+
+ Parameters +
+
+
{{ object.parameters }}
+
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 60eba6097..c58565c31 100644 --- a/netbox/templates/generic/object_list.html +++ b/netbox/templates/generic/object_list.html @@ -64,7 +64,7 @@ Context: {# Applied filters #} {% if filter_form %} - {% applied_filters filter_form request.GET %} + {% applied_filters model filter_form request.GET %} {% endif %} {# "Select all" form #} diff --git a/netbox/utilities/templates/helpers/applied_filters.html b/netbox/utilities/templates/helpers/applied_filters.html index 4f22a7c9a..3cf8fe425 100644 --- a/netbox/utilities/templates/helpers/applied_filters.html +++ b/netbox/utilities/templates/helpers/applied_filters.html @@ -10,5 +10,10 @@ Clear all {% endif %} + {% if save_link %} + + Save + + {% endif %} {% endif %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 9789724ee..231ac19f7 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,9 +1,11 @@ import datetime import decimal +from urllib.parse import quote from typing import Dict, Any from django import template from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse from django.utils import timezone @@ -278,12 +280,13 @@ def table_config_form(table, table_name=None): } -@register.inclusion_tag('helpers/applied_filters.html') -def applied_filters(form, query_params): +@register.inclusion_tag('helpers/applied_filters.html', takes_context=True) +def applied_filters(context, model, form, query_params): """ Display the active filters for a given filter form. """ - form.is_valid() + user = context['request'].user + form.is_valid() # Ensure cleaned_data has been set applied_filters = [] for filter_name in form.changed_data: @@ -305,6 +308,14 @@ def applied_filters(form, query_params): 'link_text': f'{bound_field.label}: {display_value}', }) + save_link = None + if 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}¶meters={quote(parameters)}" + return { 'applied_filters': applied_filters, + 'save_link': save_link, }