diff --git a/AUTHORS b/AUTHORS
index 771e5e62705..ad5cea2f391 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -99,6 +99,7 @@ answer newbie questions, and generally made Django that much better:
Brian Beck
Shannon -jj Behrens
Esdras Beleza
+ Božidar Benko
Chris Bennett
Danilo Bargen
Shai Berger
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index e7edccd585f..f27ed3b6533 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -1,4 +1,5 @@
import copy
+import operator
from functools import update_wrapper, partial
from django import forms
@@ -9,7 +10,7 @@ from django.forms.models import (modelform_factory, modelformset_factory,
from django.contrib.contenttypes.models import ContentType
from django.contrib.admin import widgets, helpers
from django.contrib.admin.util import (unquote, flatten_fieldsets, get_deleted_objects,
- model_format_dict, NestedObjects)
+ model_format_dict, NestedObjects, lookup_needs_distinct)
from django.contrib.admin import validation
from django.contrib.admin.templatetags.admin_static import static
from django.contrib import messages
@@ -255,6 +256,34 @@ class BaseModelAdmin(six.with_metaclass(RenameBaseModelAdminMethods)):
"""
return self.prepopulated_fields
+ def get_search_results(self, request, queryset, search_term):
+ # Apply keyword searches.
+ def construct_search(field_name):
+ if field_name.startswith('^'):
+ return "%s__istartswith" % field_name[1:]
+ elif field_name.startswith('='):
+ return "%s__iexact" % field_name[1:]
+ elif field_name.startswith('@'):
+ return "%s__search" % field_name[1:]
+ else:
+ return "%s__icontains" % field_name
+
+ use_distinct = False
+ if self.search_fields and search_term:
+ orm_lookups = [construct_search(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
+
def get_queryset(self, request):
"""
Returns a QuerySet of all model instances that can be edited by the
diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
index 050d4776d06..21ac30b7b3f 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -1,7 +1,5 @@
-import operator
import sys
import warnings
-from functools import reduce
from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
from django.core.paginator import InvalidPage
@@ -331,7 +329,7 @@ class ChangeList(six.with_metaclass(RenameChangeListMethods)):
def get_queryset(self, request):
# First, we collect all the declared list filters.
(self.filter_specs, self.has_filters, remaining_lookup_params,
- use_distinct) = self.get_filters(request)
+ filters_use_distinct) = self.get_filters(request)
# Then, we let every list filter modify the queryset to its liking.
qs = self.root_queryset
@@ -378,31 +376,11 @@ class ChangeList(six.with_metaclass(RenameChangeListMethods)):
ordering = self.get_ordering(request, qs)
qs = qs.order_by(*ordering)
- # Apply keyword searches.
- def construct_search(field_name):
- if field_name.startswith('^'):
- return "%s__istartswith" % field_name[1:]
- elif field_name.startswith('='):
- return "%s__iexact" % field_name[1:]
- elif field_name.startswith('@'):
- return "%s__search" % field_name[1:]
- else:
- return "%s__icontains" % field_name
+ # Apply search results
+ qs, search_use_distinct = self.model_admin.get_search_results(request, qs, self.query)
- if self.search_fields and self.query:
- orm_lookups = [construct_search(str(search_field))
- for search_field in self.search_fields]
- for bit in self.query.split():
- or_queries = [models.Q(**{orm_lookup: bit})
- for orm_lookup in orm_lookups]
- qs = qs.filter(reduce(operator.or_, or_queries))
- if not use_distinct:
- for search_spec in orm_lookups:
- if lookup_needs_distinct(self.lookup_opts, search_spec):
- use_distinct = True
- break
-
- if use_distinct:
+ # Remove duplicates from results, if neccesary
+ if filters_use_distinct | search_use_distinct:
return qs.distinct()
else:
return qs
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index b089416bfbc..90570f95763 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -1005,6 +1005,9 @@ subclass::
Performs a full-text match. This is like the default search method but
uses an index. Currently this is only available for MySQL.
+ If you need to customize search you can use :meth:`ModelAdmin.get_search_results` to provide additional or alternate
+ search behaviour.
+
Custom template options
~~~~~~~~~~~~~~~~~~~~~~~
@@ -1102,6 +1105,39 @@ templates used by the :class:`ModelAdmin` views:
else:
return ['name']
+.. method:: ModelAdmin.get_search_results(self, request, queryset, search_term)
+
+ .. versionadded:: 1.6
+
+ The ``get_search_results`` method modifies the list of objects displayed in
+ to those that match the provided search term. It accepts the request, a
+ queryset that applies the current filters, and the user-provided search term.
+ It returns a tuple containing a queryset modified to implement the search, and
+ a boolean indicating if the results may contain duplicates.
+
+ The default implementation searches the fields named in :attr:`ModelAdmin.search_fields`.
+
+ This method may be overridden with your own custom search method. For
+ example, you might wish to search by an integer field, or use an external
+ tool such as Solr or Haystack. You must establish if the queryset changes
+ implemented by your search method may introduce duplicates into the results,
+ and return ``True`` in the second element of the return value.
+
+ For example, to enable search by integer field, you could use::
+
+ class PersonAdmin(admin.ModelAdmin):
+ list_display = ('name', 'age')
+ search_fields = ('name',)
+
+ def get_search_results(self, request, queryset, search_term):
+ queryset, use_distinct = super(PersonAdmin, self).get_search_results(request, queryset, search_term)
+ try:
+ search_term_as_int = int(search_term)
+ queryset |= self.model.objects.filter(age=search_term_as_int)
+ except:
+ pass
+ return queryset, use_distinct
+
.. method:: ModelAdmin.save_related(self, request, form, formsets, change)
The ``save_related`` method is given the ``HttpRequest``, the parent
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index cc7585cd2d0..4e68ffb8a61 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -24,7 +24,7 @@ from .models import (Article, Chapter, Account, Media, Child, Parent, Picture,
Gadget, Villain, SuperVillain, Plot, PlotDetails, CyclicOne, CyclicTwo,
WorkHour, Reservation, FoodDelivery, RowLevelChangePermissionModel, Paper,
CoverLetter, Story, OtherStory, Book, Promo, ChapterXtra1, Pizza, Topping,
- Album, Question, Answer, ComplexSortedPerson, PrePopulatedPostLargeSlug,
+ Album, Question, Answer, ComplexSortedPerson, PluggableSearchPerson, PrePopulatedPostLargeSlug,
AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod,
AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated,
RelatedPrepopulated, UndeletableObject, UserMessenger, Simple, Choice,
@@ -530,6 +530,20 @@ class ComplexSortedPersonAdmin(admin.ModelAdmin):
colored_name.admin_order_field = 'name'
+class PluggableSearchPersonAdmin(admin.ModelAdmin):
+ list_display = ('name', 'age')
+ search_fields = ('name',)
+
+ def get_search_results(self, request, queryset, search_term):
+ queryset, use_distinct = super(PluggableSearchPersonAdmin, self).get_search_results(request, queryset, search_term)
+ try:
+ search_term_as_int = int(search_term)
+ queryset |= self.model.objects.filter(age=search_term_as_int)
+ except:
+ pass
+ return queryset, use_distinct
+
+
class AlbumAdmin(admin.ModelAdmin):
list_filter = ['title']
@@ -733,6 +747,7 @@ site.register(Question)
site.register(Answer)
site.register(PrePopulatedPost, PrePopulatedPostAdmin)
site.register(ComplexSortedPerson, ComplexSortedPersonAdmin)
+site.register(PluggableSearchPerson, PluggableSearchPersonAdmin)
site.register(PrePopulatedPostLargeSlug, PrePopulatedPostLargeSlugAdmin)
site.register(AdminOrderedField, AdminOrderedFieldAdmin)
site.register(AdminOrderedModelMethod, AdminOrderedModelMethodAdmin)
diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py
index 1916949f63f..e78dc40a6cd 100644
--- a/tests/admin_views/models.py
+++ b/tests/admin_views/models.py
@@ -591,6 +591,12 @@ class ComplexSortedPerson(models.Model):
age = models.PositiveIntegerField()
is_employee = models.NullBooleanField()
+
+class PluggableSearchPerson(models.Model):
+ name = models.CharField(max_length=100)
+ age = models.PositiveIntegerField()
+
+
class PrePopulatedPostLargeSlug(models.Model):
"""
Regression test for #15938: a large max_length for the slugfield must not
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 8e678a72b38..91dc5b4344f 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -46,7 +46,7 @@ from .models import (Article, BarAccount, CustomArticle, EmptyModel, FooAccount,
DooHickey, FancyDoodad, Whatsit, Category, Post, Plot, FunkyTag, Chapter,
Book, Promo, WorkHour, Employee, Question, Answer, Inquisition, Actor,
FoodDelivery, RowLevelChangePermissionModel, Paper, CoverLetter, Story,
- OtherStory, ComplexSortedPerson, Parent, Child, AdminOrderedField,
+ OtherStory, ComplexSortedPerson, PluggableSearchPerson, Parent, Child, AdminOrderedField,
AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable,
Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject,
Simple, UndeletableObject, Choice, ShortMessage, Telegram)
@@ -2202,6 +2202,20 @@ class AdminSearchTest(TestCase):
self.assertContains(response, "\n0 persons\n")
self.assertNotContains(response, "Guido")
+ def test_pluggable_search(self):
+ p1 = PluggableSearchPerson.objects.create(name="Bob", age=10)
+ p2 = PluggableSearchPerson.objects.create(name="Amy", age=20)
+
+ response = self.client.get('/test_admin/admin/admin_views/pluggablesearchperson/?q=Bob')
+ # confirm the search returned one object
+ self.assertContains(response, "\n1 pluggable search person\n")
+ self.assertContains(response, "Bob")
+
+ response = self.client.get('/test_admin/admin/admin_views/pluggablesearchperson/?q=20')
+ # confirm the search returned one object
+ self.assertContains(response, "\n1 pluggable search person\n")
+ self.assertContains(response, "Amy")
+
@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
class AdminInheritedInlinesTest(TestCase):