From 3f91fcb3a31e5ce3f0a8cbff4540bce4587ea805 Mon Sep 17 00:00:00 2001
From: Matt Westcott <matt@west.co.tt>
Date: Fri, 3 Jan 2025 17:05:40 +0000
Subject: [PATCH] Define base Page panels as placeholders within wagtail.models

This ensures that code such as `content_panels = Page.content_panels + [...]` works as expected even if wagtail.admin has not been loaded.

Fixes #12747
---
 wagtail/admin/panels/model_utils.py           |  5 +++
 wagtail/admin/panels/page_utils.py            | 42 +------------------
 wagtail/admin/panels/signal_handlers.py       |  2 -
 wagtail/admin/tests/pages/test_create_page.py |  2 -
 wagtail/admin/tests/test_edit_handlers.py     | 16 +++++--
 wagtail/models/__init__.py                    | 40 +++++++++++++++---
 wagtail/models/panels.py                      | 37 ++++++++++++++++
 7 files changed, 92 insertions(+), 52 deletions(-)
 create mode 100644 wagtail/models/panels.py

diff --git a/wagtail/admin/panels/model_utils.py b/wagtail/admin/panels/model_utils.py
index 0b98a81992..dd18f615b1 100644
--- a/wagtail/admin/panels/model_utils.py
+++ b/wagtail/admin/panels/model_utils.py
@@ -5,6 +5,7 @@ from django.db.models.fields.reverse_related import ManyToOneRel
 from django.forms.models import fields_for_model
 
 from wagtail.admin.forms.models import formfield_for_dbfield
+from wagtail.models import PanelPlaceholder
 
 from .base import Panel
 from .field_panel import FieldPanel
@@ -62,6 +63,10 @@ def expand_panel_list(model, panels):
         if isinstance(panel, Panel):
             result.append(panel)
 
+        elif isinstance(panel, PanelPlaceholder):
+            if real_panel := panel.construct():
+                result.append(real_panel)
+
         elif isinstance(panel, str):
             field = model._meta.get_field(panel)
             if isinstance(field, ManyToOneRel):
diff --git a/wagtail/admin/panels/page_utils.py b/wagtail/admin/panels/page_utils.py
index 58116638b1..093ee5330f 100644
--- a/wagtail/admin/panels/page_utils.py
+++ b/wagtail/admin/panels/page_utils.py
@@ -1,50 +1,12 @@
-from django.conf import settings
 from django.utils.translation import gettext_lazy
 
 from wagtail.admin.forms.pages import WagtailAdminPageForm
 from wagtail.models import Page
 from wagtail.utils.decorators import cached_classmethod
 
-from .comment_panel import CommentPanel
-from .field_panel import FieldPanel
-from .group import MultiFieldPanel, ObjectList, TabbedInterface
-from .publishing_panel import PublishingPanel
-from .title_field_panel import TitleFieldPanel
+from .group import ObjectList, TabbedInterface
 
-
-def set_default_page_edit_handlers(cls):
-    cls.content_panels = [
-        TitleFieldPanel("title"),
-    ]
-
-    cls.promote_panels = [
-        MultiFieldPanel(
-            [
-                FieldPanel("slug"),
-                FieldPanel("seo_title"),
-                FieldPanel("search_description"),
-            ],
-            gettext_lazy("For search engines"),
-        ),
-        MultiFieldPanel(
-            [
-                FieldPanel("show_in_menus"),
-            ],
-            gettext_lazy("For site menus"),
-        ),
-    ]
-
-    cls.settings_panels = [
-        PublishingPanel(),
-    ]
-
-    if getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True):
-        cls.settings_panels.append(CommentPanel())
-
-    cls.base_form_class = WagtailAdminPageForm
-
-
-set_default_page_edit_handlers(Page)
+Page.base_form_class = WagtailAdminPageForm
 
 
 @cached_classmethod
diff --git a/wagtail/admin/panels/signal_handlers.py b/wagtail/admin/panels/signal_handlers.py
index 0c108f469c..5c368c12a9 100644
--- a/wagtail/admin/panels/signal_handlers.py
+++ b/wagtail/admin/panels/signal_handlers.py
@@ -5,7 +5,6 @@ from django.dispatch import receiver
 from wagtail.models import Page
 
 from .model_utils import get_edit_handler
-from .page_utils import set_default_page_edit_handlers
 
 
 @receiver(setting_changed)
@@ -14,7 +13,6 @@ def reset_edit_handler_cache(**kwargs):
     Clear page edit handler cache when global WAGTAILADMIN_COMMENTS_ENABLED settings are changed
     """
     if kwargs["setting"] == "WAGTAILADMIN_COMMENTS_ENABLED":
-        set_default_page_edit_handlers(Page)
         for model in apps.get_models():
             if issubclass(model, Page):
                 model.get_edit_handler.cache_clear()
diff --git a/wagtail/admin/tests/pages/test_create_page.py b/wagtail/admin/tests/pages/test_create_page.py
index c8a9a65727..6bf874e868 100644
--- a/wagtail/admin/tests/pages/test_create_page.py
+++ b/wagtail/admin/tests/pages/test_create_page.py
@@ -1,5 +1,4 @@
 import datetime
-import unittest
 from unittest import mock
 
 from django.contrib.auth.models import Group, Permission
@@ -428,7 +427,6 @@ class TestPageCreation(WagtailTestUtils, TestCase):
         )
         self.assertRedirects(response, "/admin/")
 
-    @unittest.expectedFailure
     def test_create_page_defined_before_admin_load(self):
         """
         Test that a page model defined before wagtail.admin is loaded has all fields present
diff --git a/wagtail/admin/tests/test_edit_handlers.py b/wagtail/admin/tests/test_edit_handlers.py
index df261a2bbc..6a2a99a607 100644
--- a/wagtail/admin/tests/test_edit_handlers.py
+++ b/wagtail/admin/tests/test_edit_handlers.py
@@ -30,6 +30,7 @@ from wagtail.admin.panels import (
     PublishingPanel,
     TabbedInterface,
     TitleFieldPanel,
+    expand_panel_list,
     extract_panel_definitions_from_model_class,
     get_form_for_model,
 )
@@ -1726,7 +1727,10 @@ class TestCommentPanel(WagtailTestUtils, TestCase):
         Test that the comment panel is missing if WAGTAILADMIN_COMMENTS_ENABLED=False
         """
         self.assertFalse(
-            any(isinstance(panel, CommentPanel) for panel in Page.settings_panels)
+            any(
+                isinstance(panel, CommentPanel)
+                for panel in expand_panel_list(Page, Page.settings_panels)
+            )
         )
         form_class = Page.get_edit_handler().get_form_class()
         form = form_class()
@@ -1737,7 +1741,10 @@ class TestCommentPanel(WagtailTestUtils, TestCase):
         Test that the comment panel is present by default
         """
         self.assertTrue(
-            any(isinstance(panel, CommentPanel) for panel in Page.settings_panels)
+            any(
+                isinstance(panel, CommentPanel)
+                for panel in expand_panel_list(Page, Page.settings_panels)
+            )
         )
         form_class = Page.get_edit_handler().get_form_class()
         form = form_class()
@@ -2024,7 +2031,10 @@ class TestPublishingPanel(WagtailTestUtils, TestCase):
         Test that the publishing panel is present by default
         """
         self.assertTrue(
-            any(isinstance(panel, PublishingPanel) for panel in Page.settings_panels)
+            any(
+                isinstance(panel, PublishingPanel)
+                for panel in expand_panel_list(Page, Page.settings_panels)
+            )
         )
         form_class = Page.get_edit_handler().get_form_class()
         form = form_class()
diff --git a/wagtail/models/__init__.py b/wagtail/models/__init__.py
index 5d2d741e3d..58c9223339 100644
--- a/wagtail/models/__init__.py
+++ b/wagtail/models/__init__.py
@@ -128,6 +128,7 @@ from .media import (  # noqa: F401
     UploadedFile,
     get_root_collection_id,
 )
+from .panels import CommentPanelPlaceholder, PanelPlaceholder
 from .reference_index import ReferenceIndex  # noqa: F401
 from .sites import Site, SiteManager, SiteRootPath  # noqa: F401
 from .specific import SpecificMixin
@@ -1405,11 +1406,40 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         COMMENTS_RELATION_NAME,
     ]
 
-    # Define these attributes early to avoid masking errors. (Issue #3078)
-    # The canonical definition is in wagtailadmin.panels.
-    content_panels = []
-    promote_panels = []
-    settings_panels = []
+    # Real panel classes are defined in wagtail.admin.panels, which we can't import here
+    # because it would create a circular import. Instead, define them with placeholders
+    # to be replaced with the real classes by `wagtail.admin.panels.model_utils.expand_panel_list`.
+    content_panels = [
+        PanelPlaceholder("wagtail.admin.panels.TitleFieldPanel", ["title"], {}),
+    ]
+    promote_panels = [
+        PanelPlaceholder(
+            "wagtail.admin.panels.MultiFieldPanel",
+            [
+                [
+                    "slug",
+                    "seo_title",
+                    "search_description",
+                ],
+                _("For search engines"),
+            ],
+            {},
+        ),
+        PanelPlaceholder(
+            "wagtail.admin.panels.MultiFieldPanel",
+            [
+                [
+                    "show_in_menus",
+                ],
+                _("For site menus"),
+            ],
+            {},
+        ),
+    ]
+    settings_panels = [
+        PanelPlaceholder("wagtail.admin.panels.PublishingPanel", [], {}),
+        CommentPanelPlaceholder(),
+    ]
 
     # Privacy options for page
     private_page_options = ["password", "groups", "login"]
diff --git a/wagtail/models/panels.py b/wagtail/models/panels.py
new file mode 100644
index 0000000000..8e3c606632
--- /dev/null
+++ b/wagtail/models/panels.py
@@ -0,0 +1,37 @@
+# Placeholder for panel types defined in wagtail.admin.panels.
+# These are needed because we wish to define properties such as `content_panels` on core models
+# such as Page, but importing from wagtail.admin would create a circular import. We therefore use a
+# placeholder object, and swap it out for the real panel class inside
+# `wagtail.admin.panels.model_utils.expand_panel_list` at the same time as converting strings to
+# FieldPanel instances.
+
+from django.conf import settings
+from django.utils.functional import cached_property
+from django.utils.module_loading import import_string
+
+
+class PanelPlaceholder:
+    def __init__(self, path, args, kwargs):
+        self.path = path
+        self.args = args
+        self.kwargs = kwargs
+
+    @cached_property
+    def panel_class(self):
+        return import_string(self.path)
+
+    def construct(self):
+        return self.panel_class(*self.args, **self.kwargs)
+
+
+class CommentPanelPlaceholder(PanelPlaceholder):
+    def __init__(self):
+        super().__init__(
+            "wagtail.admin.panels.CommentPanel",
+            [],
+            {},
+        )
+
+    def construct(self):
+        if getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True):
+            return super().construct()