From 26a413507abb38f7eee4cf62f2ee9727fdc7bf8d Mon Sep 17 00:00:00 2001
From: Alix <alix@spacefoot.com>
Date: Tue, 26 May 2020 17:53:18 +0200
Subject: [PATCH] Fixed #6933 -- Added support for searching against quoted
 phrases in ModelAdmin.search_fields.

---
 django/contrib/admin/options.py  |  8 ++++++--
 docs/ref/contrib/admin/index.txt | 16 ++++++++++++----
 docs/releases/3.2.txt            |  3 ++-
 tests/admin_views/tests.py       | 22 +++++++++++++++++++++-
 4 files changed, 41 insertions(+), 8 deletions(-)

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 ``__<lookup>`` 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,
-            """<span class="small quiet">1 result (<a href="?">3 total</a>)</span>""",
+            """<span class="small quiet">1 result (<a href="?">5 total</a>)</span>""",
             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):