diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index b895378d37..e628afbe76 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -44,7 +44,9 @@ from django.utils.decorators import method_decorator from django.utils.html import format_html from django.utils.http import urlencode from django.utils.safestring import mark_safe -from django.utils.text import capfirst, format_lazy, get_text_list +from django.utils.text import ( + capfirst, format_lazy, get_text_list, smart_split, unescape_string_literal, +) from django.utils.translation import gettext as _, ngettext from django.views.decorators.csrf import csrf_protect from django.views.generic import RedirectView @@ -1022,7 +1024,9 @@ class ModelAdmin(BaseModelAdmin): if search_fields and search_term: orm_lookups = [construct_search(str(search_field)) for search_field in search_fields] - for bit in search_term.split(): + for bit in smart_split(search_term): + if bit.startswith(('"', "'")): + bit = unescape_string_literal(bit) or_queries = [models.Q(**{orm_lookup: bit}) for orm_lookup in orm_lookups] queryset = queryset.filter(reduce(operator.or_, or_queries)) diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index b85711d510..df956c6bf3 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -1309,14 +1309,18 @@ subclass:: WHERE (first_name ILIKE '%john%' OR last_name ILIKE '%john%') AND (first_name ILIKE '%lennon%' OR last_name ILIKE '%lennon%') + The search query can contain quoted phrases with spaces. For example, if a + user searches for ``"john winston"`` or ``'john winston'``, Django will do + the equivalent of this SQL ``WHERE`` clause: + + .. code-block:: sql + + WHERE (first_name ILIKE '%john winston%' OR last_name ILIKE '%john winston%') + If you don't want to use ``icontains`` as the lookup, you can use any lookup by appending it the field. For example, you could use :lookup:`exact` by setting ``search_fields`` to ``['first_name__exact']``. - Beware that because query terms are split and ANDed as described earlier, - searching with :lookup:`exact` only works with a single search word since - two or more words can't all be an exact match unless all words are the same. - Some (older) shortcuts for specifying a field lookup are also available. You can prefix a field in ``search_fields`` with the following characters and it's equivalent to adding ``__`` to the field: @@ -1334,6 +1338,10 @@ subclass:: :meth:`ModelAdmin.get_search_results` to provide additional or alternate search behavior. + .. versionchanged:: 3.2 + + Support for searching against quoted phrases with spaces was added. + .. attribute:: ModelAdmin.show_full_result_count Set ``show_full_result_count`` to control whether the full count of objects diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index b6db027448..9b87484335 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -37,7 +37,8 @@ Minor features :mod:`django.contrib.admin` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* :attr:`.ModelAdmin.search_fields` now allows searching against quoted phrases + with spaces. :mod:`django.contrib.admindocs` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 3094ad43a4..4d3fade3c9 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -3439,6 +3439,8 @@ class AdminSearchTest(TestCase): cls.per1 = Person.objects.create(name='John Mauchly', gender=1, alive=True) cls.per2 = Person.objects.create(name='Grace Hopper', gender=1, alive=False) cls.per3 = Person.objects.create(name='Guido van Rossum', gender=1, alive=True) + Person.objects.create(name='John Doe', gender=1) + Person.objects.create(name="John O'Hara", gender=1) cls.t1 = Recommender.objects.create() cls.t2 = Recommendation.objects.create(the_recommender=cls.t1) @@ -3513,7 +3515,7 @@ class AdminSearchTest(TestCase): response = self.client.get(reverse('admin:admin_views_person_changelist') + '?q=Gui') self.assertContains( response, - """1 result (3 total)""", + """1 result (5 total)""", html=True ) @@ -3533,6 +3535,24 @@ class AdminSearchTest(TestCase): ) self.assertTrue(response.context['cl'].show_admin_actions) + def test_search_with_spaces(self): + url = reverse('admin:admin_views_person_changelist') + '?q=%s' + tests = [ + ('"John Doe"', 1), + ("'John Doe'", 1), + ('John Doe', 0), + ('"John Doe" John', 1), + ("'John Doe' John", 1), + ("John Doe John", 0), + ('"John Do"', 1), + ("'John Do'", 1), + ("'John O\\'Hara'", 1), + ] + for search, hits in tests: + with self.subTest(search=search): + response = self.client.get(url % search) + self.assertContains(response, '\n%s person' % hits) + @override_settings(ROOT_URLCONF='admin_views.urls') class AdminInheritedInlinesTest(TestCase):