mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -06:00
Initial work on saved filters
This commit is contained in:
parent
ea61a540cd
commit
57009643fb
@ -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')),
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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',
|
||||
|
@ -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(),
|
||||
|
@ -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(),
|
||||
|
@ -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',)),
|
||||
|
@ -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(),
|
||||
|
@ -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)
|
||||
|
||||
|
@ -10,6 +10,7 @@ __all__ = (
|
||||
'ImageAttachmentType',
|
||||
'JournalEntryType',
|
||||
'ObjectChangeType',
|
||||
'SavedFilterType',
|
||||
'TagType',
|
||||
'WebhookType',
|
||||
)
|
||||
@ -71,6 +72,14 @@ class ObjectChangeType(BaseObjectType):
|
||||
filterset_class = filtersets.ObjectChangeFilterSet
|
||||
|
||||
|
||||
class SavedFilterType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.SavedFilter
|
||||
exclude = ('content_types', )
|
||||
filterset_class = filtersets.SavedFilterFilterSet
|
||||
|
||||
|
||||
class TagType(ObjectType):
|
||||
|
||||
class Meta:
|
||||
|
36
netbox/extras/migrations/0083_savedfilter.py
Normal file
36
netbox/extras/migrations/0083_savedfilter.py
Normal 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'),
|
||||
},
|
||||
),
|
||||
]
|
@ -18,6 +18,7 @@ __all__ = (
|
||||
'JournalEntry',
|
||||
'ObjectChange',
|
||||
'Report',
|
||||
'SavedFilter',
|
||||
'Script',
|
||||
'Tag',
|
||||
'TaggedItem',
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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):
|
||||
|
@ -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(
|
||||
|
@ -278,6 +278,7 @@ OTHER_MENU = Menu(
|
||||
get_model_item('extras', 'customfield', 'Custom Fields'),
|
||||
get_model_item('extras', 'customlink', 'Custom Links'),
|
||||
get_model_item('extras', 'exporttemplate', 'Export Templates'),
|
||||
get_model_item('extras', 'savedfilter', 'Saved Filters'),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
@ -4,17 +4,17 @@ from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError, ObjectDoesNotExist
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db.models import ManyToManyField, ProtectedError
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel
|
||||
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, model_to_dict
|
||||
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2.export import TableExport
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from extras.models import ExportTemplate, SavedFilter
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, PermissionsViolation
|
||||
@ -330,7 +330,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
return headers, records
|
||||
|
||||
def _update_objects(self, form, request, headers, records):
|
||||
from utilities.forms import CSVModelChoiceField
|
||||
updated_objs = []
|
||||
|
||||
ids = [int(record["id"]) for record in records]
|
||||
|
70
netbox/templates/extras/savedfilter.html
Normal file
70
netbox/templates/extras/savedfilter.html
Normal 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 %}
|
@ -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 #}
|
||||
|
@ -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 %}
|
||||
|
@ -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,
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user