Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modeladmin search backend #5208

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 28 additions & 3 deletions docs/reference/contrib/modeladmin/indexview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,36 @@ Set ``search_fields`` to enable a search box at the top of the index page
for your model. You should add names of any fields on the model that should
be searched whenever somebody submits a search query using the search box.

Searching is all handled via Django's QuerySet API, rather than using Wagtail's
search backend. This means it will work for all models, whatever search backend
Searching is handled via Django's QuerySet API by default,
see `ModelAdmin.search_handler_class`_ about changing this behaviour.
This means by default it will work for all models, whatever search backend
your project is using, and without any additional setup or configuration.

.. _modeladmin_search_handler_class:

-----------------------------------
``ModelAdmin.search_handler_class``
-----------------------------------

The default value for this property is the `DjangoORMSearchHandler`, which uses
the Django ORM to perform `icontains` lookups on the fields specified by `search_fields`.

If you would prefer to use the built-in Wagtail search backend to search your models,
assuming they are indexed, you can override this property with the included
`WagtailBackendSearchHandler` class e.g.

.. code-block:: python
from wagtail.contrib.helpers import WagtailBackendSearchHandler
seb-b marked this conversation as resolved.
Show resolved Hide resolved
from .models import IndexedModel

class DemoAdmin(ModelAdmin):
model = IndexedModel
search_handler_class = WagtailBackendSearchHandler

Note: when using the `WagtailBackendSearchHandler` the `search_fields` are used to limit
the backend to search only over the specified fields, so those fields must be indexed
by the backend.

.. _modeladmin_ordering:

---------------------------
Expand Down Expand Up @@ -646,4 +672,3 @@ See the following part of the docs to find out more:

See the following part of the docs to find out more:
:ref:`modeladmin_overriding_views`

7 changes: 4 additions & 3 deletions wagtail/contrib/modeladmin/helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .button import ButtonHelper, PageButtonHelper # NOQA
from .permission import PagePermissionHelper, PermissionHelper # NOQA
from .url import AdminURLHelper, PageAdminURLHelper # NOQA
from .button import ButtonHelper, PageButtonHelper # NOQA
from .permission import PagePermissionHelper, PermissionHelper # NOQA
from .search import DjangoORMSearchHandler, WagtailBackendSearchHandler # NOQA
from .url import AdminURLHelper, PageAdminURLHelper # NOQA
61 changes: 61 additions & 0 deletions wagtail/contrib/modeladmin/helpers/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import operator
from functools import reduce

from django.contrib.admin.utils import lookup_needs_distinct
from django.db.models import Q

from wagtail.search.backends import get_search_backend


class BaseSearchHandler:
def __init__(self, search_fields):
self.search_fields = search_fields

def search_queryset(self, queryset, search_term, **kwargs):
"""
Returns a tuple containing a queryset to implement the search,
and a boolean indicating if the results may contain duplicates.
"""
raise NotImplementedError()

ababic marked this conversation as resolved.
Show resolved Hide resolved
@property
def show_search_form(self):
"""
Returns a boolean that determines whether a search form should be
displayed in the IndexView UI
"""
return True


class DjangoORMSearchHandler(BaseSearchHandler):
def search_queryset(self, queryset, search_term):
if not search_term or not self.search_fields:
return queryset, False
use_distinct = False
orm_lookups = ['%s__icontains' % str(search_field)
for search_field in self.search_fields]
for bit in search_term.split():
or_queries = [Q(**{orm_lookup: bit})
for orm_lookup in orm_lookups]
queryset = queryset.filter(reduce(operator.or_, or_queries))
opts = queryset.model._meta
for search_spec in orm_lookups:
# Check wether out results may have duplicates, then remove them
if lookup_needs_distinct(opts, search_spec):
use_distinct = True
break
return queryset, use_distinct

@property
def show_search_form(self):
return bool(self.search_fields)


class WagtailBackendSearchHandler(BaseSearchHandler):
def search_queryset(self, queryset, search_term, backend='default'):
seb-b marked this conversation as resolved.
Show resolved Hide resolved
if not search_term:
return queryset, False
backend = get_search_backend(backend)
if self.search_fields:
return backend.search(search_term, queryset, fields=self.search_fields), False
return backend.search(search_term, queryset), False
5 changes: 3 additions & 2 deletions wagtail/contrib/modeladmin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from wagtail.core.models import Page

from .helpers import (
AdminURLHelper, ButtonHelper, PageAdminURLHelper, PageButtonHelper, PagePermissionHelper,
PermissionHelper)
AdminURLHelper, ButtonHelper, DjangoORMSearchHandler, PageAdminURLHelper, PageButtonHelper,
PagePermissionHelper, PermissionHelper)
from .menus import GroupMenuItem, ModelAdminMenuItem, SubMenu
from .mixins import ThumbnailMixin # NOQA
from .views import ChooseParentView, CreateView, DeleteView, EditView, IndexView, InspectView
Expand Down Expand Up @@ -96,6 +96,7 @@ class ModelAdmin(WagtailRegisterable):
inspect_template_name = ''
delete_template_name = ''
choose_parent_template_name = ''
search_handler_class = DjangoORMSearchHandler
permission_helper_class = None
url_helper_class = None
button_helper_class = None
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% load i18n static %}
{% if view.search_fields %}
{% if show_search %}
<form id="changelist-search" class="col search-form" action="{{ view.index_url }}" method="get">
<ul class="fields">
<li class="required">
Expand Down
6 changes: 3 additions & 3 deletions wagtail/contrib/modeladmin/tests/test_page_modeladmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ def test_filter(self):
self.assertEqual(eventpage.audience, 'public')

def test_search(self):
response = self.get(q='Someone')
response = self.get(q='moon')

self.assertEqual(response.status_code, 200)

# There are two eventpage's where the title contains 'Someone'
self.assertEqual(response.context['result_count'], 1)
# There are two eventpage's where the location is 'The moon'
self.assertEqual(response.context['result_count'], 2)

def test_ordering(self):
response = self.get(o='0.1')
Expand Down
25 changes: 24 additions & 1 deletion wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
from django.test import TestCase

from wagtail.admin.edit_handlers import FieldPanel, TabbedInterface
from wagtail.contrib.modeladmin.helpers.search import DjangoORMSearchHandler
from wagtail.images.models import Image
from wagtail.images.tests.utils import get_test_image_file
from wagtail.tests.modeladmintest.models import Author, Book, Publisher, Token
from wagtail.tests.modeladmintest.wagtail_hooks import BookModelAdmin
from wagtail.tests.utils import WagtailTestUtils


Expand Down Expand Up @@ -65,14 +67,28 @@ def test_filter(self):
for book in response.context['object_list']:
self.assertEqual(book.author_id, 1)

def test_search(self):
def test_search_indexed(self):
response = self.get(q='of')

self.assertEqual(response.status_code, 200)

# There are two books where the title contains 'of'
self.assertEqual(response.context['result_count'], 2)

def test_search_form_present(self):
# Test the backend search handler allows the search form to render
response = self.get()

self.assertContains(response, '<input id="id_q"')


def test_search_form_absent(self):
# DjangoORMSearchHandler + no search_fields, search form should be absent
with mock.patch.object(BookModelAdmin, 'search_handler_class', DjangoORMSearchHandler):
response = self.get()

self.assertNotContains(response, '<input id="id_q"')

def test_ordering(self):
response = self.get(o='0.1')

Expand Down Expand Up @@ -109,6 +125,13 @@ def setUp(self):
def get(self, **params):
return self.client.get('/admin/modeladmintest/author/', params)

def test_search(self):
response = self.get(q='Roald Dahl')

self.assertEqual(response.status_code, 200)

self.assertEqual(response.context['result_count'], 2)

def test_col_extra_class_names(self):
response = self.get()
self.assertEqual(response.status_code, 200)
Expand Down
28 changes: 5 additions & 23 deletions wagtail/contrib/modeladmin/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import operator
from collections import OrderedDict
from functools import reduce

from django import forms
from django.contrib.admin import FieldListFilter, widgets
Expand Down Expand Up @@ -31,7 +29,6 @@

from .forms import ParentChooserForm


try:
from django.db.models.sql.constants import QUERY_TERMS
except ImportError:
Expand Down Expand Up @@ -240,6 +237,7 @@ def dispatch(self, request, *args, **kwargs):
self.search_fields = self.model_admin.get_search_fields(request)
self.items_per_page = self.model_admin.list_per_page
self.select_related = self.model_admin.list_select_related
self.search_handler = self.model_admin.search_handler_class(self.search_fields)

# Get search parameters from the query string.
try:
Expand Down Expand Up @@ -270,25 +268,8 @@ def get_buttons_for_obj(self, obj):
obj, classnames_add=['button-small', 'button-secondary'])

def get_search_results(self, request, queryset, search_term):
"""
Returns a tuple containing a queryset to implement the search,
and a boolean indicating if the results may contain duplicates.
"""
use_distinct = False
if self.search_fields and search_term:
orm_lookups = ['%s__icontains' % str(search_field)
for search_field in self.search_fields]
for bit in search_term.split():
or_queries = [models.Q(**{orm_lookup: bit})
for orm_lookup in orm_lookups]
queryset = queryset.filter(reduce(operator.or_, or_queries))
if not use_distinct:
for search_spec in orm_lookups:
if lookup_needs_distinct(self.opts, search_spec):
use_distinct = True
break

return queryset, use_distinct
results, use_distinct = self.search_handler.search_queryset(queryset, search_term)
return results, use_distinct

def lookup_allowed(self, lookup, value):
# Check FKey lookups that are allowed, so that popups produced by
Expand Down Expand Up @@ -641,7 +622,8 @@ def get_context_data(self, **kwargs):
'paginator': paginator,
'page_obj': page_obj,
'object_list': page_obj.object_list,
'user_can_create': self.permission_helper.user_can_create(user)
'user_can_create': self.permission_helper.user_can_create(user),
'show_search': self.search_handler.show_search_form,
}

if self.is_pagemodel:
Expand Down
4 changes: 4 additions & 0 deletions wagtail/tests/modeladmintest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class Book(models.Model, index.Indexed):
title = models.CharField(max_length=255)
cover_image = models.ForeignKey('wagtailimages.Image', on_delete=models.SET_NULL, null=True, blank=True)

search_fields = [
index.SearchField('title'),
]

def __str__(self):
return self.title

Expand Down
5 changes: 3 additions & 2 deletions wagtail/tests/modeladmintest/wagtail_hooks.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface
from wagtail.contrib.modeladmin.helpers import WagtailBackendSearchHandler
from wagtail.contrib.modeladmin.options import (
ModelAdmin, ModelAdminGroup, ThumbnailMixin, modeladmin_register)
from wagtail.contrib.modeladmin.views import CreateView
Expand Down Expand Up @@ -47,10 +48,10 @@ class BookModelAdmin(ThumbnailMixin, ModelAdmin):
list_display = ('title', 'author', 'admin_thumb')
list_filter = ('author', )
ordering = ('title', )
search_fields = ('title', )
inspect_view_enabled = True
inspect_view_fields_exclude = ('title', )
thumb_image_field_name = 'cover_image'
search_handler_class = WagtailBackendSearchHandler

def get_extra_attrs_for_row(self, obj, context):
return {
Expand Down Expand Up @@ -78,9 +79,9 @@ class EventPageAdmin(ModelAdmin):
model = EventPage
list_display = ('title', 'date_from', 'audience')
list_filter = ('audience', )
search_fields = ('title', )
inspect_view_enabled = True
inspect_view_fields_exclude = ('feed_image', )
search_handler_class = WagtailBackendSearchHandler


class SingleEventPageAdmin(EventPageAdmin):
Expand Down