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:
parent
b06f6c1618
commit
2d309a7043
1
AUTHORS
1
AUTHORS
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
Loading…
Reference in New Issue