Repository: wagtail/wagtail

Issue:
Use `GenericForeignKey` and `GenericRevision` for `Revision` model
### Is your proposal related to a problem?

From #8441:

> ... We are not using the `GenericForeignKey` field due to complexities it adds when handling the default/specific Page models (see also [ticket 16055 in Django](https://code.djangoproject.com/ticket/16055)). However, we try to simulate the same behaviour as the `GenericForeignKey` wherever possible.

To summarise a previous discussion with @jacobtoppm and @kaedroho:

While this allowed us to make the `Revision` model generic without changing much queries in the codebase, this solution is not ideal. Making `content_object` a `@property` instead of a `GenericForeignKey` makes us lose the information of the field in `Model._meta.get_fields()`. It also removes the ability to use related queries (e.g. `revision__content_object` or `page__revisions`). Ideally, we should use `GenericForeignKey` and `GenericRelation` and fix/work around any issues with existing queries/tests that prevent us from doing so.



### Describe the solution you'd like

<!--
  Provide a clear and concise description of what you want to happen.
-->

Use `GenericForeignKey` for `content_object` in the `Revision` model, and use `GenericRelation` in the `Page` model.

### Describe alternatives you've considered

<!--
  Let us know about other solutions you've tried or researched.
-->

Leave the code as it currently is (using custom-made `@property`).

### Additional context

<!--
  Is there anything else you can add about the proposal?
  You might want to link to related issues here, if you haven't already.
-->

Some of the changes to the tests in #7221 may be useful for this, as I encountered some failing tests around the base/specific Page model when initially trying to use `GenericForeignKey`/`GenericRelation`.



Discussion:

Resolving patch:
diff --git a/wagtail/admin/views/pages/history.py b/wagtail/admin/views/pages/history.py
--- a/wagtail/admin/views/pages/history.py
+++ b/wagtail/admin/views/pages/history.py
@@ -84,7 +84,7 @@ def workflow_history_detail(request, page_id, workflow_state_id):
     # tasks may be repeated. All tasks that have been completed no matter what
     # revision needs to be displayed on this page.
     page_revisions = Revision.page_revisions.filter(
-        object_id=page.id,
+        object_id=str(page.id),
         id__in=TaskState.objects.filter(workflow_state=workflow_state).values_list(
             "page_revision_id", flat=True
         ),
diff --git a/wagtail/models/__init__.py b/wagtail/models/__init__.py
--- a/wagtail/models/__init__.py
+++ b/wagtail/models/__init__.py
@@ -19,6 +19,7 @@
 from django import forms
 from django.conf import settings
 from django.contrib.auth.models import Group
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.core import checks
 from django.core.cache import cache
@@ -317,7 +318,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         verbose_name=_("latest revision created at"), null=True, editable=False
     )
     live_revision = models.ForeignKey(
-        "Revision",
+        "wagtailcore.Revision",
         related_name="+",
         verbose_name=_("live revision"),
         on_delete=models.SET_NULL,
@@ -325,6 +326,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
         blank=True,
         editable=False,
     )
+    _revisions = GenericRelation("wagtailcore.Revision", related_query_name="page")
 
     # If non-null, this page is an alias of the linked page
     # This means the page is kept in sync with the live version
@@ -404,9 +406,9 @@ def __str__(self):
 
     @property
     def revisions(self):
-        # This acts as a replacement for Django's related manager since we don't
-        # use a GenericRelation/GenericForeignKey.
-        return Revision.page_revisions.filter(object_id=self.id)
+        # Always use the specific page instance when querying for revisions as
+        # they are always saved with the specific content_type.
+        return self.specific_deferred._revisions
 
     @classmethod
     def get_streamfield_names(cls):
@@ -918,18 +920,13 @@ def save_revision(
         # base_content_type so that we can query for page revisions without
         # having to know the specific Page type.
         revision = Revision.objects.create(
-            content_type_id=self.content_type_id,
-            base_content_type_id=get_default_page_content_type().id,
-            object_id=self.id,
+            content_object=self,
+            base_content_type=get_default_page_content_type(),
             submitted_for_moderation=submitted_for_moderation,
             user=user,
             approved_go_live_at=approved_go_live_at,
             content=self.serializable_data(),
         )
-        # This is necessary to prevent fetching a fresh page instance when
-        # changing first_published_at.
-        # Ref: https://github.com/wagtail/wagtail/pull/3498
-        revision.content_object = self
 
         for comment in new_comments:
             comment.revision_created = revision
@@ -2184,22 +2181,16 @@ def for_instance(self, instance):
             object_id=str(instance.pk),
         )
 
-    def filter(self, *args, **kwargs):
-        # Make sure the object_id is a string, so queries that use the target model's
-        # id still works even when its id is not a string
-        # (e.g. Revision.objects.filter(object_id=some_page.id)).
-        if "object_id" in kwargs:
-            kwargs["object_id"] = str(kwargs["object_id"])
-        return super().filter(*args, **kwargs)
+
+class RevisionsManager(models.Manager):
+    def for_instance(self, instance):
+        return self.get_queryset().for_instance(instance)
 
 
-class PageRevisionsManager(models.Manager):
+class PageRevisionsManager(RevisionsManager):
     def get_queryset(self):
         return RevisionQuerySet(self.model, using=self._db).page_revisions()
 
-    def for_instance(self, instance):
-        return self.get_queryset().for_instance(instance)
-
     def submitted(self):
         return self.get_queryset().submitted()
 
@@ -2208,9 +2199,6 @@ class SubmittedRevisionsManager(models.Manager):
     def get_queryset(self):
         return RevisionQuerySet(self.model, using=self._db).submitted()
 
-    def for_instance(self, instance):
-        return self.get_queryset().for_instance(instance)
-
 
 class Revision(models.Model):
     content_type = models.ForeignKey(
@@ -2241,19 +2229,17 @@ class Revision(models.Model):
         verbose_name=_("approved go live at"), null=True, blank=True, db_index=True
     )
 
-    objects = models.Manager()
+    objects = RevisionsManager()
     page_revisions = PageRevisionsManager()
     submitted_revisions = SubmittedRevisionsManager()
 
-    @cached_property
-    def content_object(self):
-        return self.base_content_type.get_object_for_this_type(pk=self.object_id)
+    content_object = GenericForeignKey(
+        "content_type", "object_id", for_concrete_model=False
+    )
 
     @cached_property
-    def specific_content_object(self):
-        if isinstance(self.content_object, Page):
-            return self.content_object.specific
-        return self.content_type.get_object_for_this_type(pk=self.object_id)
+    def base_content_object(self):
+        return self.base_content_type.get_object_for_this_type(pk=self.object_id)
 
     @property
     def page(self):
@@ -2303,17 +2289,17 @@ def save(self, user=None, *args, **kwargs):
             and "approved_go_live_at" in kwargs["update_fields"]
         ):
             # Log scheduled revision publish cancellation
-            page = self.as_object()
+            object = self.as_object()
             # go_live_at = kwargs['update_fields'][]
             log(
-                instance=page,
+                instance=object,
                 action="wagtail.schedule.cancel",
                 data={
                     "revision": {
                         "id": self.id,
                         "created": self.created_at.strftime("%d %b %Y %H:%M"),
-                        "go_live_at": page.go_live_at.strftime("%d %b %Y %H:%M")
-                        if page.go_live_at
+                        "go_live_at": object.go_live_at.strftime("%d %b %Y %H:%M")
+                        if object.go_live_at
                         else None,
                     }
                 },
@@ -2322,7 +2308,7 @@ def save(self, user=None, *args, **kwargs):
             )
 
     def as_object(self):
-        return self.specific_content_object.with_content_json(self.content)
+        return self.content_object.with_content_json(self.content)
 
     def as_page_object(self):
         warnings.warn(
@@ -3619,7 +3605,7 @@ def copy_approved_task_states_to_revision(self, revision):
     def revisions(self):
         """Returns all page revisions associated with task states linked to the current workflow state"""
         return Revision.page_revisions.filter(
-            object_id=self.page_id,
+            object_id=str(self.page_id),
             id__in=self.task_states.values_list("page_revision_id", flat=True),
         ).defer("content")
 
diff --git a/wagtail/query.py b/wagtail/query.py
--- a/wagtail/query.py
+++ b/wagtail/query.py
@@ -7,7 +7,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import CharField, Prefetch, Q
 from django.db.models.expressions import Exists, OuterRef
-from django.db.models.functions import Length, Substr
+from django.db.models.functions import Cast, Length, Substr
 from django.db.models.query import BaseIterable, ModelIterable
 from treebeard.mp_tree import MP_NodeQuerySet
 
@@ -462,7 +462,7 @@ def annotate_approved_schedule(self):
             _approved_schedule=Exists(
                 Revision.page_revisions.exclude(
                     approved_go_live_at__isnull=True
-                ).filter(object_id=OuterRef("pk"))
+                ).filter(object_id=Cast(OuterRef("pk"), output_field=CharField()))
             )
         )
 


Test patch:
diff --git a/wagtail/admin/tests/pages/test_dashboard.py b/wagtail/admin/tests/pages/test_dashboard.py
--- a/wagtail/admin/tests/pages/test_dashboard.py
+++ b/wagtail/admin/tests/pages/test_dashboard.py
@@ -92,15 +92,12 @@ def test_panel(self):
         panel = RecentEditsPanel()
         ctx = panel.get_context_data({"request": self.client})
 
+        page = Page.objects.get(pk=self.child_page.id).specific
+
         # check if the revision is the revision of edited Page
-        self.assertEqual(
-            ctx["last_edits"][0][0].content_object,
-            Page.objects.get(pk=self.child_page.id),
-        )
+        self.assertEqual(ctx["last_edits"][0][0].content_object, page)
         # check if the page in this list is the specific page of this revision
-        self.assertEqual(
-            ctx["last_edits"][0][1], Page.objects.get(pk=self.child_page.id).specific
-        )
+        self.assertEqual(ctx["last_edits"][0][1], page)
 
 
 class TestRecentEditsQueryCount(TestCase, WagtailTestUtils):
diff --git a/wagtail/admin/tests/pages/test_moderation.py b/wagtail/admin/tests/pages/test_moderation.py
--- a/wagtail/admin/tests/pages/test_moderation.py
+++ b/wagtail/admin/tests/pages/test_moderation.py
@@ -278,7 +278,8 @@ def test_approved_notifications(self):
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(mail.outbox[0].to, ["submitter@email.com"])
         self.assertEqual(
-            mail.outbox[0].subject, 'The page "Hello world!" has been approved'
+            mail.outbox[0].subject,
+            'The page "Hello world! (simple page)" has been approved',
         )
 
     def test_approved_notifications_preferences_respected(self):
@@ -304,7 +305,8 @@ def test_rejected_notifications(self):
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(mail.outbox[0].to, ["submitter@email.com"])
         self.assertEqual(
-            mail.outbox[0].subject, 'The page "Hello world!" has been rejected'
+            mail.outbox[0].subject,
+            'The page "Hello world! (simple page)" has been rejected',
         )
 
     def test_rejected_notification_preferences_respected(self):