Repository: wagtail/wagtail

Issue:
Workflow/task states with a revision pointing to a nonexistent object causes crash on the dashboard
### Issue Summary
I'm not sure how this happened but I recently fixed a bug with a production Wagtail site where we needed to delete a revision that was referencing a snippet but had the wrong ID.

Bug:

```
AttributeError at /admin/
'NoneType' object has not attribute 'snippet_viewset'
Django Version: 4.2.2
Exception Location: wagtail/admin/views/home.py, line 192, in get_context_data
Python Version: 3.11.6
Wagtail Version: 5.0.2
```

### Steps to Reproduce

1. Create 2 revisions and then submit your snippet for moderators approval.
2. I'm not sure how this part happened but to reproduce: In the Revision objects update the object_id and the pk values to point to a number that doesn't exist in the pages. So if it was pointing to page 2 make it page 999 and pk '999' for both revision objects. I was able to do this by querying them Revision.objects.filter(id) through the django shell to reproduce after the fact in a local environment.
3. Login or just go to /admin and you'll see the error that I'm seeing. It's important that the last thing you do is submit it for moderators approval though it won't happen unless you do that.

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

I'm most interested in why a snippet that does exist could be saved with everything else correct in the revision table but have the wrong ID?

For example, I have revisions saved under object_id 154 and 166 for the same snippet. Is this a configuration error possibly?

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes, but you have to manually query the revisions and update them like I described to get into the state. So, yes and no I haven't reproduced in the wild.

### Technical details

-   Python version: 3.11.6
-   Django version: 4.2.2
-   Wagtail version: 5.0.2
-   Browser version: Chrome 119



Discussion:
Thanks for the report!

Not sure if this was the exact cause of your issue, but I can replicate this by deleting a snippet or a page that is currently in moderation.

Regardless, the root cause is that the `WorkflowState` does not get deleted along with the page/snippet and revision for some reason, leaving the generic foreign key to return `None`. I know that this is the default behaviour of generic foreign keys as per the Django docs (https://docs.djangoproject.com/en/4.2/ref/contrib/contenttypes/):

> If the related object is deleted, the `content_type` and `object_id` fields remain set to their original values and the `GenericForeignKey` returns `None`:
> ```pycon
> >>> guido.delete()
> >>> t.content_object  # returns None
> ```

but the docs also say:

> Note also, that if you delete an object that has a [`GenericRelation`](https://docs.djangoproject.com/en/4.2/ref/contrib/contenttypes/#django.contrib.contenttypes.fields.GenericRelation), any objects which have a [`GenericForeignKey`](https://docs.djangoproject.com/en/4.2/ref/contrib/contenttypes/#django.contrib.contenttypes.fields.GenericForeignKey) pointing at it will be deleted as well. In the example above, this means that if a `Bookmark` object were deleted, any `TaggedItem` objects pointing at it would be deleted at the same time.

and since we do define `GenericRelation`s (at least on pages),  I thought the cascade will happen anyway.

The quick fix is to check whether the `content_object` is `None`, but I'd like to investigate further to see if there's something we missed in order to make the cascading work reliably.
OK, after looking at this again – the above only happens if you don't define a `GenericRelation` on the model. For pages, there's a default `GenericRelation` so I think we're covered. That said, I'm pretty sure I was able to replicate a similar issue with pages when I wrote https://github.com/wagtail/wagtail/issues/11300#issuecomment-1840489092, but I can't trigger it again for some reason...

The [example in the docs](https://docs.wagtail.org/en/stable/topics/snippets/features.html#enabling-workflows-for-snippets) demonstrate adding a `GenericRelation` but I think we could be more explicit about that.

Regardless, I think we should be more defensive about accessing `WorkflowState.content_object` (and perhaps `Revision.content_object` for that matter) to avoid crashing when the object does not exist for some reason. Although, this is suspiciously similar to #9185 and it would be great if we could find what could be causing this...

> I'm most interested in why a snippet that does exist could be saved with everything else correct in the revision table but have the wrong ID?
> For example, I have revisions saved under `object_id` 154 and 166 for the same snippet. Is this a configuration error possibly?

I can't see how this could've happened @freddiemixell. Could you check whether the objects with the id `154` and `166` exist and are completely different objects (not similar in content at all)? Just in case it might be that you had two similar objects and one of them was deleted, so when you looked at the revision for the deleted object it seemed as if the revision is for the other object with the id for the deleted one.

Also, do you have a `GenericRelation` to the `WorkflowState` defined on your workflow-enabled snippet model?

Resolving patch:
diff --git a/wagtail/admin/views/home.py b/wagtail/admin/views/home.py
--- a/wagtail/admin/views/home.py
+++ b/wagtail/admin/views/home.py
@@ -130,6 +130,12 @@ def get_context_data(self, parent_context):
                 )
                 .order_by("-current_task_state__started_at")
             )
+            # Filter out workflow states where the GenericForeignKey points to
+            # a nonexistent object. This can happen if the model does not define
+            # a GenericRelation to WorkflowState and the instance is deleted.
+            context["workflow_states"] = [
+                state for state in context["workflow_states"] if state.content_object
+            ]
         else:
             context["workflow_states"] = WorkflowState.objects.none()
         context["request"] = request
@@ -157,6 +163,7 @@ def get_context_data(self, parent_context):
                 "revision",
                 "task",
                 "revision__user",
+                "workflow_state",
             )
             .prefetch_related(
                 "revision__content_object",
@@ -167,6 +174,12 @@ def get_context_data(self, parent_context):
         )
         for state in states:
             obj = state.revision.content_object
+            # Skip task states where the revision's GenericForeignKey points to
+            # a nonexistent object. This can happen if the model does not define
+            # a GenericRelation to WorkflowState and/or Revision and the instance
+            # is deleted.
+            if not obj:
+                continue
             actions = state.task.specific.get_actions(obj, request.user)
             workflow_tasks = state.workflow_state.all_tasks_with_status()
 


Test patch:
diff --git a/wagtail/admin/tests/test_workflows.py b/wagtail/admin/tests/test_workflows.py
--- a/wagtail/admin/tests/test_workflows.py
+++ b/wagtail/admin/tests/test_workflows.py
@@ -3650,11 +3650,35 @@ def test_dashboard_for_moderator_with_previous_revisions(self):
             "Compare with previous version",
         )
 
+    def test_dashboard_after_deleting_object_in_moderation(self):
+        # WorkflowState's content_object may point to a nonexistent object
+        # https://github.com/wagtail/wagtail/issues/11300
+        self.login(self.submitter)
+        self.post("submit")
+        self.object.delete()
+
+        response = self.client.get(reverse("wagtailadmin_home"))
+        self.assertEqual(response.status_code, 200)
+        self.assertNotContains(response, "Your pages and snippets in a workflow")
+
+        self.login(self.moderator)
+        response = self.client.get(reverse("wagtailadmin_home"))
+        self.assertEqual(response.status_code, 200)
+        self.assertNotContains(response, "Awaiting your review")
+
 
 class TestDashboardWithSnippets(TestDashboardWithPages, BaseSnippetWorkflowTests):
     pass
 
 
+class TestDashboardWithNonLockableSnippets(TestDashboardWithSnippets):
+    # This model does not use LockableMixin, and it also does not have a
+    # GenericRelation to WorkflowState and Revision, but it should not break
+    # the dashboard.
+    # See https://github.com/wagtail/wagtail/issues/11300 for more details.
+    model = ModeratedModel
+
+
 class TestWorkflowStateEmailNotifier(BasePageWorkflowTests):
     def setUp(self):
         super().setUp()
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
@@ -1124,6 +1124,22 @@ class CountryCode(models.TextChoices):
 
     some_attribute = "some value"
 
+    workflow_states = GenericRelation(
+        "wagtailcore.WorkflowState",
+        content_type_field="base_content_type",
+        object_id_field="object_id",
+        related_query_name="full_featured_snippet",
+        for_concrete_model=False,
+    )
+
+    revisions = GenericRelation(
+        "wagtailcore.Revision",
+        content_type_field="base_content_type",
+        object_id_field="object_id",
+        related_query_name="full_featured_snippet",
+        for_concrete_model=False,
+    )
+
     search_fields = [
         index.SearchField("text"),
         index.AutocompleteField("text"),
diff --git a/wagtail/tests/test_revision_model.py b/wagtail/tests/test_revision_model.py
--- a/wagtail/tests/test_revision_model.py
+++ b/wagtail/tests/test_revision_model.py
@@ -6,6 +6,7 @@
 
 from wagtail.models import Page, Revision, get_default_page_content_type
 from wagtail.test.testapp.models import (
+    FullFeaturedSnippet,
     RevisableGrandChildModel,
     RevisableModel,
     SimplePage,
@@ -168,3 +169,25 @@ def test_is_latest_revision_same_creation_dates(self):
         # The id is used as a tie breaker
         self.assertEqual(first.created_at, second.created_at)
         self.assertLess(first.id, second.id)
+
+    def test_revision_cascade_on_object_delete(self):
+        page = self.create_page()
+        full_featured_snippet = FullFeaturedSnippet.objects.create(text="foo")
+        cases = [
+            # Tuple of (instance, cascades)
+            # For models that define a GenericRelation to Revision, the revision
+            # should be deleted when the instance is deleted.
+            (page, True),
+            (full_featured_snippet, True),
+            (self.instance, False),  # No GenericRelation to Revision
+        ]
+        for instance, cascades in cases:
+            with self.subTest(instance=instance):
+                revision = instance.save_revision()
+                query = {
+                    "base_content_type": instance.get_base_content_type(),
+                    "object_id": str(instance.pk),
+                }
+                self.assertEqual(Revision.objects.filter(**query).first(), revision)
+                instance.delete()
+                self.assertIs(Revision.objects.filter(**query).exists(), not cascades)
diff --git a/wagtail/tests/test_workflow.py b/wagtail/tests/test_workflow.py
--- a/wagtail/tests/test_workflow.py
+++ b/wagtail/tests/test_workflow.py
@@ -449,6 +449,19 @@ def test_start_workflow_group_approval_task_locked(self):
         self.assertIsNone(self.object.locked_at)
         self.assertIsNone(self.object.locked_by)
 
+    def test_workflow_state_cascade_on_object_delete(self, cascades=True):
+        data = self.start_workflow()
+        query = {
+            "base_content_type": self.object.get_base_content_type(),
+            "object_id": str(self.object.pk),
+        }
+        self.assertEqual(
+            WorkflowState.objects.filter(**query).first(),
+            data["workflow_state"],
+        )
+        self.object.delete()
+        self.assertIs(WorkflowState.objects.filter(**query).exists(), not cascades)
+
 
 class TestSnippetWorkflows(TestPageWorkflows):
     fixtures = None
@@ -479,3 +492,10 @@ def test_start_workflow_group_approval_task_locked(self):
         self.assertEqual(workflow_state.workflow, workflow)
         self.assertEqual(workflow_state.content_object, self.object)
         self.assertEqual(workflow_state.status, "in_progress")
+
+    def test_workflow_state_cascade_on_object_delete(self):
+        # We expect the cascade to not happen as the model does not define
+        # a GenericRelation to WorkflowState. However, workflows should still
+        # work as expected.
+        # See https://github.com/wagtail/wagtail/issues/11300 for more details.
+        return super().test_workflow_state_cascade_on_object_delete(cascades=False)