Skip to content

Commit

Permalink
Add support for custom search handler classes to ModelAdmin's IndexView
Browse files Browse the repository at this point in the history
Author:    Seb <seb@takeflight.com.au>
Date:      Sun Apr 7 12:34:00 2019 +1000
  • Loading branch information
Seb authored and ababic committed Jun 8, 2019
1 parent ed7ca7c commit b839bd6
Show file tree
Hide file tree
Showing 12 changed files with 382 additions and 47 deletions.
91 changes: 86 additions & 5 deletions docs/reference/contrib/modeladmin/indexview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -280,17 +280,98 @@ for your model. For example:
``ModelAdmin.search_fields``
----------------------------

**Expected value**: A list or tuple, where each item is the name of a model field
of type ``CharField``, ``TextField``, ``RichTextField`` or ``StreamField``.
**Expected value**: A list or tuple, where each item is the name of a model
field of type ``CharField``, ``TextField``, ``RichTextField`` or
``StreamField``.

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``
-----------------------------------

**Expected value**: A subclass of
``wagtail.contrib.modeladmin.helpers.search.BaseSearchHandler``

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

If you would prefer to use the built-in Wagtail search backend to search your
models, you can use the ``WagtailBackendSearchHandler`` class instead. For
example:

.. code-block:: python
from wagtail.contrib.modeladmin.helpers import WagtailBackendSearchHandler
from .models import Person
class PersonAdmin(ModelAdmin):
model = Person
search_handler_class = WagtailBackendSearchHandler
Extra considerations when using ``WagtailBackendSearchHandler``
===============================================================


``ModelAdmin.search_fields`` is used differently
------------------------------------------------

The value of ``search_fields`` is passed to the underlying search backend to
limit the fields used when matching. Each item in the list must be indexed
on your model using :ref:`wagtailsearch_index_searchfield`.

To allow matching on **any** indexed field, set the ``search_fields`` attribute
on your ``ModelAdmin`` class to ``None``, or remove it completely.


Indexing extra fields using ``index.FilterField``
-------------------------------------------------

The underlying search backend must be able to interpret all of the fields and
relationships used in the queryset created by ``IndexView``, including those
used in ``prefetch()`` or ``select_related()`` queryset methods, or used in
``list_display``, ``list_filter`` or ``ordering``.

Be sure to test things thoroughly in a development environment (ideally
using the same search backend as you use in production). Wagtail will raise
an ``IndexError`` if the backend encounters something it does not understand,
and will tell you what you need to change.


.. _modeladmin_extra_search_kwargs:

----------------------------------
``ModelAdmin.extra_search_kwargs``
----------------------------------

**Expected value**: A dictionary of keyword arguments that will be passed on to the ``search()`` method of
``search_handler_class``.

For example, to override the ``WagtailBackendSearchHandler`` default operator you could do the following:

.. code-block:: python
from wagtail.contrib.modeladmin.helpers import WagtailBackendSearchHandler
from wagtail.search.utils import OR
from .models import IndexedModel
class DemoAdmin(ModelAdmin):
model = IndexedModel
search_handler_class = WagtailBackendSearchHandler
extra_search_kwargs = {'operator': OR}
.. _modeladmin_ordering:

---------------------------
Expand Down Expand Up @@ -318,6 +399,7 @@ language) you can override the ``get_ordering()`` method instead.
Set ``list_per_page`` to control how many items appear on each paginated page
of the index view. By default, this is set to ``100``.


.. _modeladmin_get_queryset:

-----------------------------
Expand Down Expand Up @@ -646,4 +728,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`

4 changes: 4 additions & 0 deletions docs/topics/search/indexing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ This creates an ``EventPage`` model with two fields: ``description`` and ``date`
>>> EventPage.objects.filter(date__gt=timezone.now()).search("Christmas")
.. _wagtailsearch_index_searchfield:

``index.SearchField``
---------------------

Expand All @@ -113,6 +115,8 @@ Options
- **es_extra** (``dict``) - This field is to allow the developer to set or override any setting on the field in the ElasticSearch mapping. Use this if you want to make use of any ElasticSearch features that are not yet supported in Wagtail.


.. _wagtailsearch_index_filterfield:

``index.FilterField``
---------------------

Expand Down
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
72 changes: 72 additions & 0 deletions wagtail/contrib/modeladmin/helpers/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 an iterable of objects from ``queryset`` matching the
provided ``search_term``.
"""
raise NotImplementedError()

@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, **kwargs):
if not search_term or not self.search_fields:
return queryset

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:
if lookup_needs_distinct(opts, search_spec):
return queryset.distinct()
return queryset


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


class WagtailBackendSearchHandler(BaseSearchHandler):

default_search_backend = 'default'

def search_queryset(
self, queryset, search_term, preserve_order=False, operator=None,
partial_match=True, backend=None, **kwargs
):
if not search_term:
return queryset

backend = get_search_backend(backend or self.default_search_backend)
return backend.search(
search_term,
queryset,
fields=self.search_fields or None,
operator=operator,
partial_match=partial_match,
order_by_relevance=not preserve_order,
)
22 changes: 20 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,8 @@ class ModelAdmin(WagtailRegisterable):
inspect_template_name = ''
delete_template_name = ''
choose_parent_template_name = ''
search_handler_class = DjangoORMSearchHandler
extra_search_kwargs = {}
permission_helper_class = None
url_helper_class = None
button_helper_class = None
Expand Down Expand Up @@ -238,6 +240,22 @@ def get_search_fields(self, request):
"""
return self.search_fields or ()

def get_search_handler(self, request, search_fields=None):
"""
Returns an instance of ``self.search_handler_class`` that can be used by
``IndexView``.
"""
return self.search_handler_class(
search_fields or self.get_search_fields(request)
)

def get_extra_search_kwargs(self, request, search_term):
"""
Returns a dictionary of additional kwargs to be sent to
``SearchHandler.search_queryset()``.
"""
return self.extra_search_kwargs

def get_extra_attrs_for_row(self, obj, context):
"""
Return a dictionary of HTML attributes to be added to the `<tr>`
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
2 changes: 1 addition & 1 deletion wagtail/contrib/modeladmin/tests/test_page_modeladmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_search(self):

self.assertEqual(response.status_code, 200)

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

def test_ordering(self):
Expand Down

0 comments on commit b839bd6

Please sign in to comment.