Repository: wagtail/wagtail

Issue:
Search Query Behavior Issue with Multiple Fields in SnippetViewSet
### Issue Summary

I'm declaring a `SnippetViewSet` on `wagtails_hooks.py`, for an external Model. The problem arises when I specify multiple fields in the search_fields parameter. The search functionality in the Wagtail admin panel fails to work correctly. It is worth noting, that the external model does not inherit from wagtail.search.index.Indexed.

### Steps to Reproduce

To reproduce this issue, follow these steps using an external model or your own model that does not extend `wagtail.search.index.Indexed`. For example, I will use the `SocialApp` model from `django-allauth`:

1. Define a `SnippetViewSet`:
```python
from allauth.socialaccount.models import SocialApp

class SocialAppSnippetViewSet(BaseSnippetViewSet):
    model = SocialApp
    icon = "link"
    menu_order = 100
    inspect_view_enabled = True
    list_display = ("provider", "name", "client_id")
    list_filter = ["provider"]
    search_fields = ["provider", "name", "client_id"]
```

### Relevant Information
Upon analyzing the `search_queryset()` function from the IndexView code in Wagtail, we find the following code:
```python
    def search_queryset(self, queryset):
        if not self.search_query:
            return queryset

        if class_is_indexed(queryset.model) and self.search_backend_name:
            search_backend = get_search_backend(self.search_backend_name)
            return search_backend.autocomplete(
                self.search_query, queryset, fields=self.search_fields
            )

        filters = {
            field + "__icontains": self.search_query
            for field in self.search_fields or []
        }
        return queryset.filter(**filters)
```
In my case, where the class `SocialApp` is not indexed, the code performs a loop over `self.search_fields` and populates the filters dictionary. This results in a query like this, for example, if the search query was 'hello': `{'provider__icontains': 'hello', 'name__icontains': 'hello', 'client_id__icontains': 'hello'}`.  Is this intentional behavior, or should it use an OR condition?

In my case, I have rewritten the `search_queryset()` as follows:
```python
    from django.db.models import Q

    def search_queryset(self, queryset):
        if not self.search_query:
            return queryset

        if class_is_indexed(queryset.model) and self.search_backend_name:
            search_backend = get_search_backend(self.search_backend_name)
            return search_backend.autocomplete(
                self.search_query, queryset, fields=self.search_fields
            )
            
        query = Q()
        for field in self.search_fields or []:
            query |= Q(**{field + "__icontains": self.search_query})

        return queryset.filter(query)
```
With this modification, my query searches for a `SocialApp` with a 'provider' that contains 'hello,' or a 'name' that contains 'hello,' or a 'client_id' that contains 'hello.' This differs from the previous behavior, where it searched for all fields simultaneously with 'hello.'

### Technical details

- Python version: 3.11.5.
- Django version: 4.2.5.
- Wagtail version: 5.1.1.
- Browser version: Chrome 117.


Discussion:
Hey, thank you for the report! I believe this is the same thing I pointed out in https://github.com/wagtail/wagtail/pull/10488/files#r1217914939. A PR would be welcome!
Hey @laymonage @AndreMorais98 I can do the changes and raise a PR if that's fine.
Yep, go ahead!

Resolving patch:
diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -9,6 +9,7 @@
     PermissionDenied,
 )
 from django.db import models, transaction
+from django.db.models import Q
 from django.db.models.functions import Cast
 from django.forms import Form
 from django.http import Http404, HttpResponseRedirect
@@ -295,12 +296,10 @@ def search_queryset(self, queryset):
                 return search_backend.search(
                     self.search_query, queryset, fields=self.search_fields
                 )
-
-        filters = {
-            field + "__icontains": self.search_query
-            for field in self.search_fields or []
-        }
-        return queryset.filter(**filters)
+        query = Q()
+        for field in self.search_fields or []:
+            query |= Q(**{field + "__icontains": self.search_query})
+        return queryset.filter(query)
 
     def _get_title_column(self, field_name, column_class=TitleColumn, **kwargs):
         if not issubclass(column_class, ButtonsColumnMixin):


Test patch:
diff --git a/wagtail/admin/tests/viewsets/test_model_viewset.py b/wagtail/admin/tests/viewsets/test_model_viewset.py
--- a/wagtail/admin/tests/viewsets/test_model_viewset.py
+++ b/wagtail/admin/tests/viewsets/test_model_viewset.py
@@ -17,6 +17,7 @@
 from wagtail.test.testapp.models import (
     FeatureCompleteToy,
     JSONStreamModel,
+    SearchTestModel,
     VariousOnDeleteModel,
 )
 from wagtail.test.utils.template_tests import AdminTemplateTestUtils
@@ -452,6 +453,44 @@ def assertInputRendered(self, response, search_q):
         pass
 
 
+class TestSearchFields(WagtailTestUtils, TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        objects = [
+            SearchTestModel(title="Hello World", body="This one is classic"),
+            SearchTestModel(title="Hello Anime", body="We love anime (opinions vary)"),
+            SearchTestModel(title="Food", body="I like food, do you?"),
+        ]
+        SearchTestModel.objects.bulk_create(objects)
+
+    def setUp(self):
+        self.login()
+
+    def get(self, q):
+        return self.client.get(reverse("searchtest:index"), {"q": q})
+
+    def test_single_result_with_body(self):
+        response = self.get("IkE")
+        self.assertEqual(response.status_code, 200)
+        self.assertNotContains(response, "Hello World")
+        self.assertNotContains(response, "Hello Anime")
+        self.assertContains(response, "Food")
+
+    def test_multiple_results_with_title(self):
+        response = self.get("ELlo")
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Hello World")
+        self.assertContains(response, "Hello Anime")
+        self.assertNotContains(response, "Food")
+
+    def test_no_results(self):
+        response = self.get("Abra Kadabra")
+        self.assertEqual(response.status_code, 200)
+        self.assertNotContains(response, "Hello World")
+        self.assertNotContains(response, "Hello Anime")
+        self.assertNotContains(response, "Food")
+
+
 class TestListExport(WagtailTestUtils, TestCase):
     def setUp(self):
         self.user = self.login()
diff --git a/wagtail/test/testapp/migrations/0031_searchtestmodel.py b/wagtail/test/testapp/migrations/0031_searchtestmodel.py
new file mode 100644
--- /dev/null
+++ b/wagtail/test/testapp/migrations/0031_searchtestmodel.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.7 on 2023-11-08 14:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("tests", "0030_purgerevisionsprotectedtestmodel"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="SearchTestModel",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("title", models.CharField(max_length=255)),
+                ("body", models.TextField()),
+            ],
+        ),
+    ]
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -2177,3 +2177,15 @@ class PurgeRevisionsProtectedTestModel(models.Model):
     revision = models.OneToOneField(
         "wagtailcore.Revision", on_delete=models.PROTECT, related_name="+"
     )
+
+
+class SearchTestModel(models.Model):
+    title = models.CharField(max_length=255)
+    body = models.TextField()
+    panels = [
+        FieldPanel("title"),
+        FieldPanel("body"),
+    ]
+
+    def __str__(self):
+        return self.title
diff --git a/wagtail/test/testapp/views.py b/wagtail/test/testapp/views.py
--- a/wagtail/test/testapp/views.py
+++ b/wagtail/test/testapp/views.py
@@ -24,6 +24,7 @@
     JSONMinMaxCountStreamModel,
     JSONStreamModel,
     ModelWithStringTypePrimaryKey,
+    SearchTestModel,
 )
 
 
@@ -196,6 +197,12 @@ class JSONModelViewSetGroup(ModelViewSetGroup):
     )
 
 
+class SearchTestModelViewSet(ModelViewSet):
+    model = SearchTestModel
+    search_fields = ["title", "body"]
+    form_fields = ["title", "body"]
+
+
 class FeatureCompleteToyViewSet(ModelViewSet):
     model = FeatureCompleteToy
     url_namespace = "feature_complete_toy"
diff --git a/wagtail/test/testapp/wagtail_hooks.py b/wagtail/test/testapp/wagtail_hooks.py
--- a/wagtail/test/testapp/wagtail_hooks.py
+++ b/wagtail/test/testapp/wagtail_hooks.py
@@ -35,6 +35,7 @@
 from wagtail.test.testapp.views import (
     JSONModelViewSetGroup,
     MiscellaneousViewSetGroup,
+    SearchTestModelViewSet,
     ToyViewSetGroup,
     animated_advert_chooser_viewset,
 )
@@ -242,7 +243,11 @@ def add_broken_links_summary_item(request, items):
 
 @hooks.register("register_admin_viewset")
 def register_viewsets():
-    return [MiscellaneousViewSetGroup(), JSONModelViewSetGroup()]
+    return [
+        MiscellaneousViewSetGroup(),
+        JSONModelViewSetGroup(),
+        SearchTestModelViewSet(name="searchtest"),
+    ]
 
 
 @hooks.register("register_admin_viewset")