Repository: wagtail/wagtail

Issue:
Wagtail 6.2rc1 - Migration issue
### Issue Summary

Wagtail 6.2rc1, `python manage.py migrate`.  Looks like while doing something with users in the migrations, something is breaking.
 
Here is the stack trace:

```
(.venv) % python manage.py migrate   
Operations to perform:
  Apply all migrations: accounts, admin, ai, articles, auth, authors, awards, banking, brokerages, calculators, contenttypes, conversions, creditcards, cryptocurrency, db, django_rq, ecap, insurance, loans, marketing, mortgages, pages, personalfinance, press, quizzes, research, sessions, shopping, sites, smallbusiness, snippetfinder, student_loans, taggit, taxes, wagtail_ai, wagtail_content_import, wagtail_transfer, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailforms, wagtailimages, wagtailredirects, wagtailsearch, wagtailusers
Traceback (most recent call last):
  File "/Users/twhite/PycharmProjects/the-ascent/manage.py", line 10, in <module>
    execute_from_command_line(sys.argv)
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/core/management/__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/core/management/base.py", line 413, in run_from_argv
    self.execute(*args, **cmd_options)
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/core/management/base.py", line 459, in execute
    output = self.handle(*args, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/core/management/base.py", line 107, in wrapper
    res = handle_func(*args, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/core/management/commands/migrate.py", line 302, in handle
    pre_migrate_apps = pre_migrate_state.apps
                       ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/utils/functional.py", line 47, in __get__
    res = instance.__dict__[self.name] = self.func(instance)
                                         ^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/db/migrations/state.py", line 566, in apps
    return StateApps(self.real_apps, self.models)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/db/migrations/state.py", line 627, in __init__
    self.render_multiple([*models.values(), *self.real_models])
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/db/migrations/state.py", line 665, in render_multiple
    model.render(self)
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/db/migrations/state.py", line 949, in render
    body = {name: field.clone() for name, field in self.fields.items()}
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/db/migrations/state.py", line 949, in <dictcomp>
    body = {name: field.clone() for name, field in self.fields.items()}
                  ^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py", line 665, in clone
    name, path, args, kwargs = self.deconstruct()
                               ^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/wagtail/fields.py", line 169, in deconstruct
    block_types = [
                  ^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/wagtail/fields.py", line 170, in <listcomp>
    (name, lookup.add_block(block))
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/wagtail/blocks/definition_lookup.py", line 70, in add_block
    deconstructed = block.deconstruct_with_lookup(self)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/wagtail/blocks/struct_block.py", line 326, in deconstruct_with_lookup
    [
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/wagtail/blocks/struct_block.py", line 327, in <listcomp>
    (name, lookup.add_block(block))
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/wagtail/blocks/definition_lookup.py", line 70, in add_block
    deconstructed = block.deconstruct_with_lookup(self)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/twhite/PycharmProjects/the-ascent/.venv/lib/python3.11/site-packages/wagtail/blocks/list_block.py", line 407, in deconstruct_with_lookup
    if isinstance(args[0], Block):
                  ~~~~^^^
IndexError: tuple index out of range
```

Discussion:
squash-labs[bot]: <h2>Manage this branch in <a href="https://app.squash.io/accounts/">Squash</a></h2>Test this branch here: <a href='https://gasmanfixlistblock-subclass-de-st80i.squash.io' target='_blank'>https://gasmanfixlistblock-subclass-de-st80i.squash.io</a><br/>

Patch:
diff --git a/docs/topics/search/searching.md b/docs/topics/search/searching.md
index b551c47cc1..ccf1510b4e 100644
--- a/docs/topics/search/searching.md
+++ b/docs/topics/search/searching.md
@@ -128,7 +128,7 @@ The search operator specifies how the search should behave when the user has typ
 
 Both operators have benefits and drawbacks. The "or" operator will return many more results but will likely contain a lot of results that aren't relevant. The "and" operator only returns results that contain all search terms but requires the user to be more precise with their query.
 
-We recommend using the "or" operator when ordering by relevance and the "and" operator when ordering by anything else (note: the database backend doesn't currently support ordering by relevance).
+We recommend using the "or" operator when ordering by relevance and the "and" operator when ordering by anything else.
 
 Here's an example of using the `operator` keyword argument:
 
diff --git a/wagtail/blocks/list_block.py b/wagtail/blocks/list_block.py
index cb73f79ddb..152dda0f51 100644
--- a/wagtail/blocks/list_block.py
+++ b/wagtail/blocks/list_block.py
@@ -151,11 +151,20 @@ class ListBlock(Block):
             # Default to a list consisting of one empty (i.e. default-valued) child item
             self.meta.default = [self.child_block.get_default()]
 
+    # If a subclass of ListBlock overrides __init__, we cannot assume that the first argument is
+    # the child block, and thus we cannot rely on the conversion applied in construct_from_lookup /
+    # deconstruct_with_lookup to be valid. We set a flag attribute on the __init__ method so that
+    # we can spot this case.
+    __init__.has_child_block_arg = True
+
     @classmethod
-    def construct_from_lookup(cls, lookup, child_block, **kwargs):
-        if isinstance(child_block, int):
-            child_block = lookup.get_block(child_block)
-        return cls(child_block, **kwargs)
+    def construct_from_lookup(cls, lookup, *args, **kwargs):
+        if getattr(cls.__init__, "has_child_block_arg", False) and isinstance(
+            args[0], int
+        ):
+            child_block = lookup.get_block(args[0])
+            args = (child_block, *args[1:])
+        return cls(*args, **kwargs)
 
     def value_from_datadict(self, data, files, prefix):
         count = int(data["%s-count" % prefix])
@@ -404,8 +413,11 @@ class ListBlock(Block):
 
     def deconstruct_with_lookup(self, lookup):
         path, args, kwargs = super().deconstruct_with_lookup(lookup)
-        if isinstance(args[0], Block):
-            args = (lookup.add_block(args[0]),)
+        if getattr(self.__init__, "has_child_block_arg", False) and isinstance(
+            args[0], Block
+        ):
+            block_id = lookup.add_block(args[0])
+            args = (block_id, *args[1:])
         return path, args, kwargs
 
     class Meta:


Test patch:
diff --git a/wagtail/tests/test_streamfield.py b/wagtail/tests/test_streamfield.py
index 6ad72b7e85..727847a678 100644
--- a/wagtail/tests/test_streamfield.py
+++ b/wagtail/tests/test_streamfield.py
@@ -824,6 +824,13 @@ class TestConstructStreamFieldFromLookup(TestCase):
         self.assertEqual(link_text_block.name, "link_text")
 
 
+# Used by TestDeconstructStreamFieldWithLookup.test_deconstruct_with_listblock_subclass -
+# needs to be a module-level definition so that the path returned from deconstruct is valid
+class BulletListBlock(blocks.ListBlock):
+    def __init__(self, **kwargs):
+        super().__init__(blocks.CharBlock(required=True), **kwargs)
+
+
 class TestDeconstructStreamFieldWithLookup(TestCase):
     def test_deconstruct(self):
         class ButtonBlock(blocks.StructBlock):
@@ -874,3 +881,73 @@ class TestDeconstructStreamFieldWithLookup(TestCase):
                 },
             },
         )
+
+    def test_deconstruct_with_listblock(self):
+        field = StreamField(
+            [
+                ("heading", blocks.CharBlock(required=True)),
+                ("bullets", blocks.ListBlock(blocks.CharBlock(required=True))),
+            ],
+            blank=True,
+        )
+        field.set_attributes_from_name("body")
+        name, path, args, kwargs = field.deconstruct()
+        self.assertEqual(name, "body")
+        self.assertEqual(path, "wagtail.fields.StreamField")
+        self.assertEqual(
+            args,
+            [
+                [
+                    ("heading", 0),
+                    ("bullets", 1),
+                ]
+            ],
+        )
+        self.assertEqual(
+            kwargs,
+            {
+                "blank": True,
+                "block_lookup": {
+                    0: ("wagtail.blocks.CharBlock", (), {"required": True}),
+                    1: ("wagtail.blocks.ListBlock", (0,), {}),
+                },
+            },
+        )
+
+    def test_deconstruct_with_listblock_subclass(self):
+        # See https://github.com/wagtail/wagtail/issues/12164 - unlike StructBlock and StreamBlock,
+        # ListBlock's deconstruct method doesn't reduce subclasses to the base ListBlock class.
+        # Therefore, if a ListBlock subclass defines its own __init__ method with an incompatible
+        # signature to the base ListBlock, this custom signature will be preserved in the result of
+        # deconstruct(), and we cannot rely on the first argument being the child block.
+
+        field = StreamField(
+            [
+                ("heading", blocks.CharBlock(required=True)),
+                ("bullets", BulletListBlock()),
+            ],
+            blank=True,
+        )
+        field.set_attributes_from_name("body")
+        name, path, args, kwargs = field.deconstruct()
+        self.assertEqual(name, "body")
+        self.assertEqual(path, "wagtail.fields.StreamField")
+        self.assertEqual(
+            args,
+            [
+                [
+                    ("heading", 0),
+                    ("bullets", 1),
+                ]
+            ],
+        )
+        self.assertEqual(
+            kwargs,
+            {
+                "blank": True,
+                "block_lookup": {
+                    0: ("wagtail.blocks.CharBlock", (), {"required": True}),
+                    1: ("wagtail.tests.test_streamfield.BulletListBlock", (), {}),
+                },
+            },
+        )