Fixed #15961 -- Modified ModelAdmin to allow for custom search methods.

This adds a get_search_results method that users can override to
provide custom search strategies.

Thanks to Daniele Procida for help with the docs.
This commit is contained in:
Bozidar Benko 2013-05-19 10:52:29 +02:00
parent b06f6c1618
commit 2d309a7043
7 changed files with 109 additions and 30 deletions

View File

@ -99,6 +99,7 @@ answer newbie questions, and generally made Django that much better:
Brian Beck <http://blog.brianbeck.com/> Brian Beck <http://blog.brianbeck.com/>
Shannon -jj Behrens <http://jjinux.blogspot.com/> Shannon -jj Behrens <http://jjinux.blogspot.com/>
Esdras Beleza <linux@esdrasbeleza.com> Esdras Beleza <linux@esdrasbeleza.com>
Božidar Benko <bbenko@gmail.com>
Chris Bennett <chrisrbennett@yahoo.com> Chris Bennett <chrisrbennett@yahoo.com>
Danilo Bargen Danilo Bargen
Shai Berger <shai@platonix.com> Shai Berger <shai@platonix.com>

View File

@ -1,4 +1,5 @@
import copy import copy
import operator
from functools import update_wrapper, partial from functools import update_wrapper, partial
from django import forms 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.contenttypes.models import ContentType
from django.contrib.admin import widgets, helpers from django.contrib.admin import widgets, helpers
from django.contrib.admin.util import (unquote, flatten_fieldsets, get_deleted_objects, 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 import validation
from django.contrib.admin.templatetags.admin_static import static from django.contrib.admin.templatetags.admin_static import static
from django.contrib import messages from django.contrib import messages
@ -255,6 +256,34 @@ class BaseModelAdmin(six.with_metaclass(RenameBaseModelAdminMethods)):
""" """
return self.prepopulated_fields 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): def get_queryset(self, request):
""" """
Returns a QuerySet of all model instances that can be edited by the Returns a QuerySet of all model instances that can be edited by the

View File

@ -1,7 +1,5 @@
import operator
import sys import sys
import warnings import warnings
from functools import reduce
from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
from django.core.paginator import InvalidPage from django.core.paginator import InvalidPage
@ -331,7 +329,7 @@ class ChangeList(six.with_metaclass(RenameChangeListMethods)):
def get_queryset(self, request): def get_queryset(self, request):
# First, we collect all the declared list filters. # First, we collect all the declared list filters.
(self.filter_specs, self.has_filters, remaining_lookup_params, (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. # Then, we let every list filter modify the queryset to its liking.
qs = self.root_queryset qs = self.root_queryset
@ -378,31 +376,11 @@ class ChangeList(six.with_metaclass(RenameChangeListMethods)):
ordering = self.get_ordering(request, qs) ordering = self.get_ordering(request, qs)
qs = qs.order_by(*ordering) qs = qs.order_by(*ordering)
# Apply keyword searches. # Apply search results
def construct_search(field_name): qs, search_use_distinct = self.model_admin.get_search_results(request, qs, self.query)
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
if self.search_fields and self.query: # Remove duplicates from results, if neccesary
orm_lookups = [construct_search(str(search_field)) if filters_use_distinct | search_use_distinct:
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:
return qs.distinct() return qs.distinct()
else: else:
return qs return qs

View File

@ -1005,6 +1005,9 @@ subclass::
Performs a full-text match. This is like the default search method but Performs a full-text match. This is like the default search method but
uses an index. Currently this is only available for MySQL. 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 Custom template options
~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~
@ -1102,6 +1105,39 @@ templates used by the :class:`ModelAdmin` views:
else: else:
return ['name'] 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) .. method:: ModelAdmin.save_related(self, request, form, formsets, change)
The ``save_related`` method is given the ``HttpRequest``, the parent The ``save_related`` method is given the ``HttpRequest``, the parent

View File

@ -24,7 +24,7 @@ from .models import (Article, Chapter, Account, Media, Child, Parent, Picture,
Gadget, Villain, SuperVillain, Plot, PlotDetails, CyclicOne, CyclicTwo, Gadget, Villain, SuperVillain, Plot, PlotDetails, CyclicOne, CyclicTwo,
WorkHour, Reservation, FoodDelivery, RowLevelChangePermissionModel, Paper, WorkHour, Reservation, FoodDelivery, RowLevelChangePermissionModel, Paper,
CoverLetter, Story, OtherStory, Book, Promo, ChapterXtra1, Pizza, Topping, CoverLetter, Story, OtherStory, Book, Promo, ChapterXtra1, Pizza, Topping,
Album, Question, Answer, ComplexSortedPerson, PrePopulatedPostLargeSlug, Album, Question, Answer, ComplexSortedPerson, PluggableSearchPerson, PrePopulatedPostLargeSlug,
AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedField, AdminOrderedModelMethod, AdminOrderedAdminMethod,
AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated, AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated,
RelatedPrepopulated, UndeletableObject, UserMessenger, Simple, Choice, RelatedPrepopulated, UndeletableObject, UserMessenger, Simple, Choice,
@ -530,6 +530,20 @@ class ComplexSortedPersonAdmin(admin.ModelAdmin):
colored_name.admin_order_field = 'name' 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): class AlbumAdmin(admin.ModelAdmin):
list_filter = ['title'] list_filter = ['title']
@ -733,6 +747,7 @@ site.register(Question)
site.register(Answer) site.register(Answer)
site.register(PrePopulatedPost, PrePopulatedPostAdmin) site.register(PrePopulatedPost, PrePopulatedPostAdmin)
site.register(ComplexSortedPerson, ComplexSortedPersonAdmin) site.register(ComplexSortedPerson, ComplexSortedPersonAdmin)
site.register(PluggableSearchPerson, PluggableSearchPersonAdmin)
site.register(PrePopulatedPostLargeSlug, PrePopulatedPostLargeSlugAdmin) site.register(PrePopulatedPostLargeSlug, PrePopulatedPostLargeSlugAdmin)
site.register(AdminOrderedField, AdminOrderedFieldAdmin) site.register(AdminOrderedField, AdminOrderedFieldAdmin)
site.register(AdminOrderedModelMethod, AdminOrderedModelMethodAdmin) site.register(AdminOrderedModelMethod, AdminOrderedModelMethodAdmin)

View File

@ -591,6 +591,12 @@ class ComplexSortedPerson(models.Model):
age = models.PositiveIntegerField() age = models.PositiveIntegerField()
is_employee = models.NullBooleanField() is_employee = models.NullBooleanField()
class PluggableSearchPerson(models.Model):
name = models.CharField(max_length=100)
age = models.PositiveIntegerField()
class PrePopulatedPostLargeSlug(models.Model): class PrePopulatedPostLargeSlug(models.Model):
""" """
Regression test for #15938: a large max_length for the slugfield must not Regression test for #15938: a large max_length for the slugfield must not

View File

@ -46,7 +46,7 @@ from .models import (Article, BarAccount, CustomArticle, EmptyModel, FooAccount,
DooHickey, FancyDoodad, Whatsit, Category, Post, Plot, FunkyTag, Chapter, DooHickey, FancyDoodad, Whatsit, Category, Post, Plot, FunkyTag, Chapter,
Book, Promo, WorkHour, Employee, Question, Answer, Inquisition, Actor, Book, Promo, WorkHour, Employee, Question, Answer, Inquisition, Actor,
FoodDelivery, RowLevelChangePermissionModel, Paper, CoverLetter, Story, FoodDelivery, RowLevelChangePermissionModel, Paper, CoverLetter, Story,
OtherStory, ComplexSortedPerson, Parent, Child, AdminOrderedField, OtherStory, ComplexSortedPerson, PluggableSearchPerson, Parent, Child, AdminOrderedField,
AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable, AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable,
Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject, Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject,
Simple, UndeletableObject, Choice, ShortMessage, Telegram) Simple, UndeletableObject, Choice, ShortMessage, Telegram)
@ -2202,6 +2202,20 @@ class AdminSearchTest(TestCase):
self.assertContains(response, "\n0 persons\n") self.assertContains(response, "\n0 persons\n")
self.assertNotContains(response, "Guido") 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',)) @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
class AdminInheritedInlinesTest(TestCase): class AdminInheritedInlinesTest(TestCase):