diff --git a/docs/reference/contrib/modeladmin/indexview.rst b/docs/reference/contrib/modeladmin/indexview.rst index 13cb73623..d9208b9a5 100644 --- a/docs/reference/contrib/modeladmin/indexview.rst +++ b/docs/reference/contrib/modeladmin/indexview.rst @@ -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: --------------------------- @@ -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: ----------------------------- @@ -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` - diff --git a/docs/topics/search/indexing.rst b/docs/topics/search/indexing.rst index 8a3851c0d..11060f298 100644 --- a/docs/topics/search/indexing.rst +++ b/docs/topics/search/indexing.rst @@ -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`` --------------------- @@ -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`` --------------------- diff --git a/wagtail/contrib/modeladmin/helpers/__init__.py b/wagtail/contrib/modeladmin/helpers/__init__.py index d7119085d..2fea64c52 100644 --- a/wagtail/contrib/modeladmin/helpers/__init__.py +++ b/wagtail/contrib/modeladmin/helpers/__init__.py @@ -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 diff --git a/wagtail/contrib/modeladmin/helpers/search.py b/wagtail/contrib/modeladmin/helpers/search.py new file mode 100644 index 000000000..6c8bc9fb0 --- /dev/null +++ b/wagtail/contrib/modeladmin/helpers/search.py @@ -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, + ) diff --git a/wagtail/contrib/modeladmin/options.py b/wagtail/contrib/modeladmin/options.py index 0de3e34ad..f0b102535 100644 --- a/wagtail/contrib/modeladmin/options.py +++ b/wagtail/contrib/modeladmin/options.py @@ -12,8 +12,8 @@ from wagtail.core import hooks 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 @@ -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 @@ -238,6 +240,22 @@ class ModelAdmin(WagtailRegisterable): """ 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 `