Repository: wagtail/wagtail

Issue:
wagtail 4.0rc1 migration ValueError: Could not find object DocumentChooserBlock
### Issue Summary

```
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, core, custom_documents, custom_images, custom_users, home, ...,
  No migrations to apply.
Traceback (most recent call last):
  File "django/./manage.py", line 12, in <module>
    execute_from_command_line(sys.argv)
  File ".venv/lib/python3.10/site-packages/django/core/management/__init__.py", line 419, in execute_from_command_line
    utility.execute()
  File ".venv/lib/python3.10/site-packages/django/core/management/__init__.py", line 413, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File ".venv/lib/python3.10/site-packages/django/core/management/base.py", line 354, in run_from_argv
    self.execute(*args, **cmd_options)
  File ".venv/lib/python3.10/site-packages/django/core/management/base.py", line 398, in execute
    output = self.handle(*args, **options)
  File ".venv/lib/python3.10/site-packages/django/core/management/base.py", line 89, in wrapped
    res = handle_func(*args, **kwargs)
  File ".venv/lib/python3.10/site-packages/django/core/management/commands/migrate.py", line 227, in handle
    changes = autodetector.changes(graph=executor.loader.graph)
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 41, in changes
    changes = self._detect_changes(convert_apps, graph)
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 184, in _detect_changes
    self.generate_altered_fields()
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 964, in generate_altered_fields
    old_field_dec = self.deep_deconstruct(old_field)
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 78, in deep_deconstruct
    [self.deep_deconstruct(value) for value in args],
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 78, in <listcomp>
    [self.deep_deconstruct(value) for value in args],
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 54, in deep_deconstruct
    return [self.deep_deconstruct(value) for value in obj]
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 54, in <listcomp>
    return [self.deep_deconstruct(value) for value in obj]
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 56, in deep_deconstruct
    return tuple(self.deep_deconstruct(value) for value in obj)
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 56, in <genexpr>
    return tuple(self.deep_deconstruct(value) for value in obj)
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 78, in deep_deconstruct
    [self.deep_deconstruct(value) for value in args],
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 78, in <listcomp>
    [self.deep_deconstruct(value) for value in args],
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 54, in deep_deconstruct
    return [self.deep_deconstruct(value) for value in obj]
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 54, in <listcomp>
    return [self.deep_deconstruct(value) for value in obj]
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 56, in deep_deconstruct
    return tuple(self.deep_deconstruct(value) for value in obj)
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 56, in <genexpr>
    return tuple(self.deep_deconstruct(value) for value in obj)
  File ".venv/lib/python3.10/site-packages/django/db/migrations/autodetector.py", line 71, in deep_deconstruct
    deconstructed = obj.deconstruct()
  File ".venv/lib/python3.10/site-packages/wagtail/blocks/base.py", line 344, in deconstruct
    raise ValueError(
ValueError: Could not find object CustomDocumentChooserBlock in wagtail.blocks.base.
Please note that you cannot serialize things like inner classes. Please move the object into the main module body to use migrations.
```

I'm using a custom document model (`WAGTAILDOCS_DOCUMENT_MODEL = "custom_documents.CustomDocument"`) so I removed `WAGTAILDOCS_DOCUMENT_MODEL` setting it see if the error persisted and when running `./manage.py migrate` the ValueError changes to:

```
ValueError: Could not find object DocumentChooserBlock in wagtail.blocks.base.
```

`CustomDocumentChooserBlock` -> `DocumentChooserBlock` 


### Steps to Reproduce

1. run `./manage.py migrate` or `./manage.py makemigrations`

-  I couldn't reproduce this on a fresh install of wagtail 4.0rc1 however I have been unable to find a cause for this error in my project so far.

### Technical details

-   Python version: 3.10.5
-   Django version: 3.2.15
-   Wagtail version: 4.0rc1
-
Ensure DocumentChooserBlock can be deconstructed for migrations
Fixes #8989. Now that DocumentChooserBlock is constructed dynamically via wagtail.documents.viewsets.chooser, we need to explicitly set its `__module__` attribute so that the result of calling `deconstruct()` for migrations points back to the wagtail.documents.blocks module.

Also update the documentation for defining custom choosers, and add tests for deconstructing the other chooser blocks.



Discussion:
Steps to recreate from scratch:

```
pip install wagtail==4.0rc1
wagtail start mysite
cd mysite
pip install -r requirements.txt
./manage.py migrate
cat > home/models.py <<EOF
from django.db import models

from wagtail.admin.panels import FieldPanel
from wagtail.blocks import CharBlock, StreamBlock, StructBlock
from wagtail.documents.blocks import DocumentChooserBlock
from wagtail.fields import StreamField
from wagtail.models import Page


class ResourceBlock(StructBlock):
    title = CharBlock()
    document = DocumentChooserBlock()


class DemoStreamBlock(StreamBlock):
    resource = ResourceBlock()


class HomePage(Page):
    content = StreamField(DemoStreamBlock, use_json_field=True, blank=True)

    content_panels = Page.content_panels + [
        FieldPanel("content", classname="full"),
    ]
EOF
./manage.py makemigrations
```
Thanks for the simple test case @tomkins. I can confirm your test case works for me on Wagtail 3.0.1 but fails on 4.0rc1.

At first glance I wonder if this error has something to do with https://github.com/wagtail/wagtail/commit/252f9dcc1fee3faec11a1aeefcfa5bd24cb7c4cf - as of this commit, DocumentChooserBlock is no longer defined "[in] the main module body" per the error message, but rather as a class created dynamically [here](https://github.com/wagtail/wagtail/blob/5ff6922eb517015651b9012bd6534a912a50b449/wagtail/admin/viewsets/chooser.py#L166-L186).

This reminds me of something that has surprised me in the past: when blocks that inherit from from wagtail.blocks.ChooserBlock are serialized for migrations, they don't get serialized as "plain instances" as documented [here](https://docs.wagtail.org/en/stable/advanced_topics/customisation/streamfield_blocks.html#handling-block-definitions-within-migrations), like, say, blocks that inherit from ChoiceBlock do. Presumably this is because ChooserBlocks require some custom code implementation in a way that other block types do not?
PR created: #9004

> This reminds me of something that has surprised me in the past: when blocks that inherit from from wagtail.blocks.ChooserBlock are serialized for migrations, they don't get serialized as "plain instances" as documented [here](https://docs.wagtail.org/en/stable/advanced_topics/customisation/streamfield_blocks.html#handling-block-definitions-within-migrations), like, say, blocks that inherit from ChoiceBlock do. Presumably this is because ChooserBlocks require some custom code implementation in a way that other block types do not?

Yep, that's correct. Whenever a custom Python object (such as a block object) appears in a model definition, the result of calling `deconstruct()` on that object will be "baked in" to the relevant migration files, which means that the creator of that object is effectively making a commitment that its definition will remain in place forever (or else people would have to go back and fix up historical migration files). This sort of commitment is reasonable for "extension code" (i.e. the sort of thing you might make into a Wagtail package), but not for "application code" (since the whole point of migrations is that developers should be able to add and remove things freely).

In the case of StructBlock and StreamBlock, there's a standardised pattern for subclassing them in a data-driven way within application code, and the base StructBlock / StreamBlock classes know enough about that pattern to be able to deconstruct them back into basic instances (providing they're not doing any backwards-incompatible code-level customisations alongside specifying child blocks), so the application developer isn't bound by that commitment. On the other hand, subclasses of ChooserBlock are liable to differ from the base class in ways that the base class can't know about, so there's no way to build an equivalent block object inside the migration without importing the subclass. And that's fine, because subclassing ChooserBlock is within the realm of "extension code", and people doing that are expected to commit to keeping those definitions around (or come up with their own `deconstruct()` logic to avoid that).
@gasman I think this hasn't been fixed completely, as it seems like it only works when the custom document model is named "Document".
In our case, but also the original case above, and also if you just follow the [docs](https://docs.wagtail.org/en/latest/advanced_topics/documents/custom_document_model.html), when using a different model name the error remains.

Our model for example is named `ExtendedDocument`, so the error now says (tested with the latest commits containing #9004):
```
ValueError: Could not find object ExtendedDocumentChooserBlock in wagtail.blocks.base.
Please note that you cannot serialize things like inner classes. Please move the object into the main module body to use migrations.
```

The `wagtail.admin.viewsets.chooser.ChooserViewSet.block_class` creates a class named `"%sChooserBlock" % self.model_name`, so this seems to make problems.
I have also tested on the latest commits and can confirm the issue persists when using a different name for the custom document model.
:+1: Merged with blackening of the docs change + changelog entries in a6a94a9a04a70334db73672024f9cafb99f5e0c9.
Cherry picked to 4.0 stable 5324720899f5ca7e9e0e890f0f9dfe53be59b66d

Resolving patch:
diff --git a/wagtail/admin/viewsets/chooser.py b/wagtail/admin/viewsets/chooser.py
--- a/wagtail/admin/viewsets/chooser.py
+++ b/wagtail/admin/viewsets/chooser.py
@@ -163,10 +163,13 @@ def widget_class(self):
             },
         )
 
-    @cached_property
-    def block_class(self):
+    def get_block_class(self, name=None, module_path=None):
         """
         Returns a StreamField ChooserBlock class using this chooser.
+
+        :param name: Name to give to the class; defaults to the model name with "ChooserBlock" appended
+        :param module_path: The dotted path of the module where the class can be imported from; used when
+            deconstructing the block definition for migration files.
         """
         meta = type(
             "Meta",
@@ -175,8 +178,8 @@ def block_class(self):
                 "icon": self.icon,
             },
         )
-        return type(
-            "%sChooserBlock" % self.model_name,
+        cls = type(
+            name or "%sChooserBlock" % self.model_name,
             (self.base_block_class,),
             {
                 "target_model": self.model,
@@ -184,6 +187,9 @@ def block_class(self):
                 "Meta": meta,
             },
         )
+        if module_path:
+            cls.__module__ = module_path
+        return cls
 
     def get_urlpatterns(self):
         return super().get_urlpatterns() + [
diff --git a/wagtail/documents/blocks.py b/wagtail/documents/blocks.py
--- a/wagtail/documents/blocks.py
+++ b/wagtail/documents/blocks.py
@@ -1,7 +1,5 @@
 from wagtail.documents.views.chooser import viewset as chooser_viewset
 
-DocumentChooserBlock = chooser_viewset.block_class
-
-# When deconstructing a DocumentChooserBlock instance for migrations, the module path
-# used in migrations should point to this module
-DocumentChooserBlock.__module__ = "wagtail.documents.blocks"
+DocumentChooserBlock = chooser_viewset.get_block_class(
+    name="DocumentChooserBlock", module_path="wagtail.documents.blocks"
+)


Test patch: