From 2d309a7043e3625cfeeadbc252322e5599dfffc0 Mon Sep 17 00:00:00 2001 From: Bozidar Benko Date: Sun, 19 May 2013 10:52:29 +0200 Subject: [PATCH] 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. --- AUTHORS | 1 + django/contrib/admin/options.py | 31 ++++++++++++++++++++++++- django/contrib/admin/views/main.py | 32 +++++--------------------- docs/ref/contrib/admin/index.txt | 36 ++++++++++++++++++++++++++++++ tests/admin_views/admin.py | 17 +++++++++++++- tests/admin_views/models.py | 6 +++++ tests/admin_views/tests.py | 16 ++++++++++++- 7 files changed, 109 insertions(+), 30 deletions(-) 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):