kopia lustrzana https://github.com/wagtail/wagtail
Move search implementation from IndexView to BaseListingView
rodzic
c36b891e35
commit
000d417ec9
|
@ -1,47 +1,21 @@
|
|||
{% extends "wagtailadmin/generic/listing_results.html" %}
|
||||
{% load i18n wagtailadmin_tags %}
|
||||
|
||||
{% block before_results %}
|
||||
{{ block.super }}
|
||||
|
||||
{% block other_searches %}
|
||||
{% if is_searching and view.show_other_searches %}
|
||||
<div class="nice-padding">
|
||||
{% search_other %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not object_list %}
|
||||
{# pass, to allow the same logic as `if object_list and (is_searching or is_filtering)` #}
|
||||
{% elif is_searching or is_filtering %}
|
||||
<div class="nice-padding">
|
||||
<h2 role="alert">
|
||||
{% blocktrans trimmed with counter=items_count|intcomma count counter_val=items_count %}
|
||||
There is {{ counter }} match
|
||||
{% plural %}
|
||||
There are {{ counter }} matches
|
||||
{% endblocktrans %}
|
||||
</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block no_results_message %}
|
||||
{% fragment as no_results_message %}
|
||||
{% if is_searching or is_filtering %}
|
||||
{% blocktrans trimmed with model_name=model_opts.verbose_name_plural asvar no_results_text %}
|
||||
No {{ model_name }} match your query.
|
||||
{% endblocktrans %}
|
||||
{% elif add_url %}
|
||||
{% blocktrans trimmed with model_name=model_opts.verbose_name_plural asvar no_results_text %}
|
||||
There are no {{ model_name }} to display. Why not <a href="{{ add_url }}">add one</a>?
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with model_name=model_opts.verbose_name_plural asvar no_results_text %}
|
||||
There are no {{ model_name }} to display.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{{ no_results_text|capfirst }}
|
||||
{% endfragment %}
|
||||
|
||||
<p>{{ no_results_message }}</p>
|
||||
{% if add_url and not is_searching and not is_filtering %}
|
||||
{% blocktrans trimmed with model_name=verbose_name_plural asvar no_results_text %}
|
||||
There are no {{ model_name }} to display. Why not <a href="{{ add_url }}">add one</a>?
|
||||
{% endblocktrans %}
|
||||
<p>{{ no_results_text|capfirst }}</p>
|
||||
{% else %}
|
||||
{{ block.super }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -18,6 +18,22 @@
|
|||
{% endfor %}
|
||||
</template>
|
||||
{% endif %}
|
||||
|
||||
{% block other_searches %}{% endblock %}
|
||||
|
||||
{% if not object_list %}
|
||||
{# pass, to allow the same logic as `if object_list and (is_searching or is_filtering)` #}
|
||||
{% elif is_searching or is_filtering %}
|
||||
<div class="nice-padding">
|
||||
<h2 role="alert">
|
||||
{% blocktrans trimmed with counter=items_count|intcomma count counter_val=items_count %}
|
||||
There is {{ counter }} match
|
||||
{% plural %}
|
||||
There are {{ counter }} matches
|
||||
{% endblocktrans %}
|
||||
</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% if object_list %}
|
||||
|
@ -33,6 +49,23 @@
|
|||
{% endblock %}
|
||||
{% else %}
|
||||
<div class="nice-padding w-mt-8">
|
||||
{% block no_results_message %}<p>{% trans "There are no results." %}</p>{% endblock %}
|
||||
{% fragment as no_results_message %}
|
||||
{% if not verbose_name_plural %}
|
||||
{% trans "There are no results." as no_results_text %}
|
||||
{% elif is_searching or is_filtering %}
|
||||
{% blocktrans trimmed with model_name=verbose_name_plural asvar no_results_text %}
|
||||
No {{ model_name }} match your query.
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with model_name=verbose_name_plural asvar no_results_text %}
|
||||
There are no {{ model_name }} to display.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{{ no_results_text|capfirst }}
|
||||
{% endfragment %}
|
||||
|
||||
{% block no_results_message %}
|
||||
<p>{{ no_results_message }}</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -451,6 +451,7 @@ class TestSearchIndexView(WagtailTestUtils, TestCase):
|
|||
|
||||
def test_search_disabled(self):
|
||||
response = self.get("fctoy_alt1", {"q": "ork"})
|
||||
self.assertFalse(response.context.get("search_form"))
|
||||
self.assertContains(response, "Forky")
|
||||
self.assertContains(response, "Buzz Lightyear")
|
||||
self.assertNotContains(response, "There are 2 matches")
|
||||
|
@ -1043,6 +1044,11 @@ class TestHistoryView(WagtailTestUtils, TestCase):
|
|||
for rendered_row, expected_row in zip(rendered_rows, expected):
|
||||
self.assertSequenceEqual(rendered_row, expected_row)
|
||||
|
||||
# History view is not searchable
|
||||
input = soup.select_one("input#id_q")
|
||||
self.assertIsNone(input)
|
||||
self.assertFalse(response.context.get("search_form"))
|
||||
|
||||
def test_action_filter(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -1241,6 +1247,11 @@ class TestUsageView(WagtailTestUtils, TestCase):
|
|||
self.assertIsNotNone(link)
|
||||
self.assertIn(tbx_edit_url, link.attrs.get("href"))
|
||||
|
||||
# Usage view is not searchable
|
||||
input = soup.select_one("input#id_q")
|
||||
self.assertIsNone(input)
|
||||
self.assertFalse(response.context.get("search_form"))
|
||||
|
||||
def test_usage_without_permission(self):
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import warnings
|
||||
from collections import namedtuple
|
||||
|
||||
from django.contrib.admin.utils import quote, unquote
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.formats import date_format
|
||||
|
@ -20,9 +22,12 @@ from django_filters.filters import (
|
|||
)
|
||||
|
||||
from wagtail.admin import messages
|
||||
from wagtail.admin.forms.search import SearchForm
|
||||
from wagtail.admin.ui.tables import Column, Table
|
||||
from wagtail.admin.utils import get_valid_next_url_from_request
|
||||
from wagtail.admin.widgets.button import ButtonWithDropdown
|
||||
from wagtail.search.backends import get_search_backend
|
||||
from wagtail.search.index import class_is_indexed
|
||||
from wagtail.utils.utils import flatten_choices
|
||||
|
||||
|
||||
|
@ -191,8 +196,13 @@ class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
|
|||
index_url_name = None
|
||||
index_results_url_name = None
|
||||
page_kwarg = "p"
|
||||
is_searchable = None # Subclasses must explicitly set this to True to enable search
|
||||
search_kwarg = "q"
|
||||
search_fields = None
|
||||
search_backend_name = "default"
|
||||
default_ordering = None
|
||||
filterset_class = None
|
||||
verbose_name_plural = None
|
||||
_show_breadcrumbs = True
|
||||
|
||||
def get_template_names(self):
|
||||
|
@ -212,6 +222,65 @@ class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
|
|||
},
|
||||
]
|
||||
|
||||
def get_search_form(self):
|
||||
if not self.is_searchable:
|
||||
return None
|
||||
|
||||
if self.search_kwarg in self.request.GET:
|
||||
return SearchForm(self.request.GET)
|
||||
|
||||
return SearchForm()
|
||||
|
||||
@cached_property
|
||||
def search_form(self):
|
||||
return self.get_search_form()
|
||||
|
||||
@cached_property
|
||||
def search_query(self):
|
||||
if self.search_form and self.search_form.is_valid():
|
||||
return self.search_form.cleaned_data[self.search_kwarg]
|
||||
return ""
|
||||
|
||||
@cached_property
|
||||
def is_searching(self):
|
||||
return bool(self.search_query)
|
||||
|
||||
def search_queryset(self, queryset):
|
||||
if not self.is_searching:
|
||||
return queryset
|
||||
|
||||
# Use Wagtail Search if the model is indexed and a search backend is defined.
|
||||
# Django ORM can still be used on an indexed model by unsetting
|
||||
# search_backend_name and defining search_fields on the view.
|
||||
if class_is_indexed(queryset.model) and self.search_backend_name:
|
||||
search_backend = get_search_backend(self.search_backend_name)
|
||||
if queryset.model.get_autocomplete_search_fields():
|
||||
return search_backend.autocomplete(
|
||||
self.search_query,
|
||||
queryset,
|
||||
fields=self.search_fields,
|
||||
order_by_relevance=(not self.is_explicitly_ordered),
|
||||
)
|
||||
else:
|
||||
# fall back on non-autocompleting search
|
||||
warnings.warn(
|
||||
f"{queryset.model} is defined as Indexable but does not specify "
|
||||
"any AutocompleteFields. Searches within the admin will only "
|
||||
"respond to complete words.",
|
||||
category=RuntimeWarning,
|
||||
)
|
||||
return search_backend.search(
|
||||
self.search_query,
|
||||
queryset,
|
||||
fields=self.search_fields,
|
||||
order_by_relevance=(not self.is_explicitly_ordered),
|
||||
)
|
||||
|
||||
query = Q()
|
||||
for field in self.search_fields or []:
|
||||
query |= Q(**{field + "__icontains": self.search_query})
|
||||
return queryset.filter(query)
|
||||
|
||||
@cached_property
|
||||
def filters(self):
|
||||
if self.filterset_class:
|
||||
|
@ -415,6 +484,7 @@ class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
|
|||
queryset = self.get_base_queryset()
|
||||
queryset = self.order_queryset(queryset)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
queryset = self.search_queryset(queryset)
|
||||
return queryset
|
||||
|
||||
def paginate_queryset(self, queryset, page_size):
|
||||
|
@ -466,6 +536,7 @@ class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
|
|||
|
||||
context["index_url"] = self.index_url
|
||||
context["index_results_url"] = self.index_results_url
|
||||
context["verbose_name_plural"] = self.verbose_name_plural
|
||||
context["table"] = table
|
||||
context["media"] = table.media
|
||||
# On Django's BaseListView, a listing where pagination is applied, but the results
|
||||
|
@ -484,6 +555,12 @@ class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
|
|||
context["is_filtering"] = self.is_filtering
|
||||
context["media"] += self.filters.form.media
|
||||
|
||||
if self.search_form:
|
||||
context["search_form"] = self.search_form
|
||||
context["is_searching"] = self.is_searching
|
||||
context["query_string"] = self.search_query
|
||||
context["media"] += self.search_form.media
|
||||
|
||||
# If we're rendering the results as an HTML fragment, the caller can pass a _w_filter_fragment=1
|
||||
# URL parameter to indicate that the filters should be rendered as a <template> block so that
|
||||
# we can replace the existing filters.
|
||||
|
@ -492,6 +569,8 @@ class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
|
|||
and self.filters
|
||||
and self.results_only
|
||||
)
|
||||
# Ensure that the header buttons get re-rendered for the results-only view,
|
||||
# in case they make use of the search/filter state
|
||||
context["render_buttons_fragment"] = (
|
||||
context.get("header_buttons") and self.results_only
|
||||
)
|
||||
|
|
|
@ -200,7 +200,6 @@ class HistoryView(PermissionCheckedMixin, BaseObjectMixin, BaseListingView):
|
|||
page_title = gettext_lazy("History")
|
||||
results_template_name = "wagtailadmin/generic/history_results.html"
|
||||
header_icon = "history"
|
||||
is_searchable = False
|
||||
paginate_by = 20
|
||||
filterset_class = HistoryFilterSet
|
||||
history_url_name = None
|
||||
|
@ -298,10 +297,13 @@ class HistoryView(PermissionCheckedMixin, BaseObjectMixin, BaseListingView):
|
|||
def user_can_unschedule(self):
|
||||
return self.user_has_permission("publish")
|
||||
|
||||
@cached_property
|
||||
def verbose_name_plural(self):
|
||||
return BaseLogEntry._meta.verbose_name_plural
|
||||
|
||||
def get_context_data(self, *args, object_list=None, **kwargs):
|
||||
context = super().get_context_data(*args, object_list=object_list, **kwargs)
|
||||
context["object"] = self.object
|
||||
context["model_opts"] = BaseLogEntry._meta
|
||||
return context
|
||||
|
||||
def get_base_queryset(self):
|
||||
|
|
|
@ -8,7 +8,6 @@ from django.core.exceptions import (
|
|||
PermissionDenied,
|
||||
)
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.db.models.functions import Cast
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
@ -29,7 +28,6 @@ from wagtail.actions.unpublish import UnpublishAction
|
|||
from wagtail.admin import messages
|
||||
from wagtail.admin.filters import WagtailFilterSet
|
||||
from wagtail.admin.forms.models import WagtailAdminModelForm
|
||||
from wagtail.admin.forms.search import SearchForm
|
||||
from wagtail.admin.panels import get_edit_handler
|
||||
from wagtail.admin.ui.components import Component, MediaContainer
|
||||
from wagtail.admin.ui.fields import display_class_registry
|
||||
|
@ -51,8 +49,8 @@ from wagtail.log_actions import log
|
|||
from wagtail.log_actions import registry as log_registry
|
||||
from wagtail.models import DraftStateMixin, Locale, ReferenceIndex
|
||||
from wagtail.models.audit_log import ModelLogEntry
|
||||
from wagtail.search.backends import get_search_backend
|
||||
from wagtail.search.index import class_is_indexed
|
||||
from wagtail.utils.deprecation import RemovedInWagtail70Warning
|
||||
|
||||
from .base import BaseListingView, WagtailAdminTemplateMixin
|
||||
from .mixins import BeforeAfterHookMixin, HookResponseMixin, LocaleMixin, PanelMixin
|
||||
|
@ -74,50 +72,37 @@ class IndexView(
|
|||
inspect_url_name = None
|
||||
delete_url_name = None
|
||||
any_permission_required = ["add", "change", "delete", "view"]
|
||||
search_fields = None
|
||||
search_backend_name = "default"
|
||||
is_searchable = None
|
||||
search_kwarg = "q"
|
||||
columns = None # If not explicitly specified, will be derived from list_display
|
||||
list_display = ["__str__", UpdatedAtColumn()]
|
||||
list_filter = None
|
||||
show_other_searches = False
|
||||
|
||||
def setup(self, request, *args, **kwargs):
|
||||
super().setup(request, *args, **kwargs)
|
||||
self.setup_search()
|
||||
|
||||
def setup_search(self):
|
||||
self.is_searchable = self.get_is_searchable()
|
||||
self.search_url = self.get_search_url()
|
||||
self.search_form = self.get_search_form()
|
||||
self.is_searching = False
|
||||
self.search_query = None
|
||||
|
||||
if self.search_form and self.search_form.is_valid():
|
||||
self.search_query = self.search_form.cleaned_data[self.search_kwarg]
|
||||
self.is_searching = bool(self.search_query)
|
||||
|
||||
def get_is_searchable(self):
|
||||
if self.model is None:
|
||||
return False
|
||||
if self.is_searchable is None:
|
||||
return class_is_indexed(self.model) or self.search_fields
|
||||
return self.is_searchable
|
||||
|
||||
def get_search_url(self):
|
||||
if not self.is_searchable:
|
||||
return None
|
||||
# This is only used by views that do not use breadcrumbs, thus uses the
|
||||
# legacy header.html. The search in that header template accepts both
|
||||
# the search_url (which really should be search_url_name) and the
|
||||
# index_results_url. This means we can advise using the latter instead,
|
||||
# without having to instruct how to set up breadcrumbs.
|
||||
warnings.warn(
|
||||
"`IndexView.get_search_url` is deprecated. "
|
||||
"Use `IndexView.get_index_results_url` instead.",
|
||||
RemovedInWagtail70Warning,
|
||||
)
|
||||
return self.index_url_name
|
||||
|
||||
def get_search_form(self):
|
||||
if self.model is None or not self.is_searchable:
|
||||
return None
|
||||
@cached_property
|
||||
def search_url(self):
|
||||
return self.get_search_url()
|
||||
|
||||
if self.is_searchable and self.search_kwarg in self.request.GET:
|
||||
return SearchForm(self.request.GET)
|
||||
@cached_property
|
||||
def is_searchable(self):
|
||||
# Do not automatically enable search if the model is not indexed and
|
||||
# search_fields is not defined.
|
||||
if not class_is_indexed(self.model) and not self.search_fields:
|
||||
return False
|
||||
|
||||
return SearchForm()
|
||||
# Require the results-only view to be set up before enabling search
|
||||
return bool(self.index_results_url or self.search_url)
|
||||
|
||||
@cached_property
|
||||
def filterset_class(self):
|
||||
|
@ -193,43 +178,6 @@ class IndexView(
|
|||
|
||||
return queryset
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
queryset = self.search_queryset(queryset)
|
||||
return queryset
|
||||
|
||||
def search_queryset(self, queryset):
|
||||
if not self.is_searching:
|
||||
return queryset
|
||||
|
||||
if class_is_indexed(queryset.model) and self.search_backend_name:
|
||||
search_backend = get_search_backend(self.search_backend_name)
|
||||
if queryset.model.get_autocomplete_search_fields():
|
||||
return search_backend.autocomplete(
|
||||
self.search_query,
|
||||
queryset,
|
||||
fields=self.search_fields,
|
||||
order_by_relevance=(not self.is_explicitly_ordered),
|
||||
)
|
||||
else:
|
||||
# fall back on non-autocompleting search
|
||||
warnings.warn(
|
||||
f"{queryset.model} is defined as Indexable but does not specify "
|
||||
"any AutocompleteFields. Searches within the admin will only "
|
||||
"respond to complete words.",
|
||||
category=RuntimeWarning,
|
||||
)
|
||||
return search_backend.search(
|
||||
self.search_query,
|
||||
queryset,
|
||||
fields=self.search_fields,
|
||||
order_by_relevance=(not self.is_explicitly_ordered),
|
||||
)
|
||||
query = Q()
|
||||
for field in self.search_fields or []:
|
||||
query |= Q(**{field + "__icontains": self.search_query})
|
||||
return queryset.filter(query)
|
||||
|
||||
def _get_title_column_class(self, column_class):
|
||||
if not issubclass(column_class, ButtonsColumnMixin):
|
||||
|
||||
|
@ -442,6 +390,12 @@ class IndexView(
|
|||
)
|
||||
return _("Add")
|
||||
|
||||
@cached_property
|
||||
def verbose_name_plural(self):
|
||||
if self.model:
|
||||
return self.model._meta.verbose_name_plural
|
||||
return None
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
|
||||
|
@ -450,11 +404,12 @@ class IndexView(
|
|||
context["add_url"] = context["header_action_url"] = self.add_url
|
||||
context["header_action_label"] = self.add_item_label
|
||||
|
||||
context["is_searchable"] = self.is_searchable
|
||||
context["search_url"] = self.get_search_url()
|
||||
context["search_form"] = self.search_form
|
||||
context["is_searching"] = self.is_searching
|
||||
context["query_string"] = self.search_query
|
||||
# RemovedInWagtail70Warning:
|
||||
# Remove these in favor of using search_form and index_results_url
|
||||
if self.is_searchable and not self.index_results_url:
|
||||
context["is_searchable"] = self.is_searchable
|
||||
context["search_url"] = self.search_url
|
||||
|
||||
context["model_opts"] = self.model and self.model._meta
|
||||
return context
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ from taggit.models import Tag
|
|||
from wagtail import hooks
|
||||
from wagtail.admin.admin_url_finder import AdminURLFinder
|
||||
from wagtail.admin.forms import WagtailAdminModelForm
|
||||
from wagtail.admin.forms.search import SearchForm
|
||||
from wagtail.admin.menu import admin_menu
|
||||
from wagtail.admin.panels import FieldPanel, ObjectList, get_edit_handler
|
||||
from wagtail.admin.widgets.button import ButtonWithDropdown
|
||||
|
@ -225,7 +226,7 @@ class TestSnippetListView(WagtailTestUtils, TestCase):
|
|||
self.assertContains(self.get(), "Add advert")
|
||||
|
||||
def test_not_searchable(self):
|
||||
self.assertFalse(self.get().context["is_searchable"])
|
||||
self.assertFalse(self.get().context.get("search_form"))
|
||||
|
||||
def test_register_snippet_listing_buttons_hook(self):
|
||||
advert = Advert.objects.create(text="My Lovely advert")
|
||||
|
@ -714,7 +715,7 @@ class TestSnippetListViewWithSearchableSnippet(WagtailTestUtils, TransactionTest
|
|||
self.assertNotContains(response, "This field is required.")
|
||||
|
||||
def test_is_searchable(self):
|
||||
self.assertTrue(self.get().context["is_searchable"])
|
||||
self.assertIsInstance(self.get().context["search_form"], SearchForm)
|
||||
|
||||
def test_search_hello(self):
|
||||
response = self.get({"q": "Hello"})
|
||||
|
|
|
@ -15,6 +15,7 @@ from django.utils.timezone import now
|
|||
from openpyxl import load_workbook
|
||||
|
||||
from wagtail.admin.admin_url_finder import AdminURLFinder
|
||||
from wagtail.admin.forms.search import SearchForm
|
||||
from wagtail.admin.menu import admin_menu, settings_menu
|
||||
from wagtail.admin.panels import get_edit_handler
|
||||
from wagtail.admin.staticfiles import versioned_static
|
||||
|
@ -1077,7 +1078,7 @@ class TestDjangoORMSearchBackend(BaseSnippetViewSetTests):
|
|||
self.assertNotContains(response, "This field is required.")
|
||||
|
||||
def test_is_searchable(self):
|
||||
self.assertTrue(self.get().context["is_searchable"])
|
||||
self.assertIsInstance(self.get().context["search_form"], SearchForm)
|
||||
|
||||
def test_search_index_view(self):
|
||||
response = self.get({"q": "Django"})
|
||||
|
|
|
@ -148,6 +148,8 @@ class IndexView(generic.IndexView):
|
|||
results_template_name = "wagtailusers/users/index_results.html"
|
||||
add_item_label = gettext_lazy("Add a user")
|
||||
context_object_name = "users"
|
||||
# We don't set search_fields and the model may not be indexed, but we override
|
||||
# search_queryset, so we set is_searchable to True to enable search
|
||||
is_searchable = True
|
||||
page_title = gettext_lazy("Users")
|
||||
show_other_searches = True
|
||||
|
|
Ładowanie…
Reference in New Issue