We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Avoid purging Revisions in use by third-party packages
I originally raised this as part of some rework on the `purge_revisions` management command in https://github.com/wagtail/wagtail/pull/10619#pullrequestreview-1528373346. We considered it out of scope to fix the issue in that PR since Wagtail has always worked like this. Hence I'm raising it again separately in this issue.

### Is your proposal related to a problem?

Wagtail provides a `purge_revisions` management command to delete stale `Revision` objects from the database. This command is aware of revisions that are still in use by Wagtail's workflow feature and avoids deleting those revisions ([link to source](https://github.com/wagtail/wagtail/blob/3d0282573d0d23f9eabe2fb6427e58b01f79ffe0/wagtail/management/commands/purge_revisions.py#L61-L66)).

However, it is somewhat common for third-party packages to rely on a specific revision. [wagtail-ab-testing](https://github.com/wagtail-nest/wagtail-ab-testing/blob/d04bd0032c4335a839c781851b12fe16037950de/wagtail_ab_testing/models.py#L76) is a real world example of this. It maintains a ForeignKey to a specific revision to track an AB experiment. If `purge_revisions` were to remove a revision in use by wagtail-ab-testing, experiment data loss would occur.

Data loss can be prevented by specifying the ForeignKey with `on_delete=models.PROTECT` but `purge_revisions` wouldn't be able to handle this and crash with a `ProtectedError`. This means third-party packages have no recourse: either they allow the revisions they depend on to be deleted or they mark their relations as protected and break Wagtails `purge_revisions` command.

### Describe the solution you'd like

I propose `purge_revisions` gracefully recovers from `ProtectedError` when trying to delete a revision. I also propose we document that relations to a `Revision` should be marked with `on_delete=models.PROTECT` if data loss is not desirable.

I also feel like the `purge_revisions` command should report the number of revisions that it couldn't delete because they were protected.

### Describe alternatives you've considered

An alternative mechanism I considered would be an API that allows packages to inform Wagtail which revisions they still use. The `purge_revisions` command can take this information into account and avoid revisions still in use.

This is a more complex solution with no apparent benefit over the solution described above.
Avoid purging Revisions in use by third-party packages
I originally raised this as part of some rework on the `purge_revisions` management command in https://github.com/wagtail/wagtail/pull/10619#pullrequestreview-1528373346. We considered it out of scope to fix the issue in that PR since Wagtail has always worked like this. Hence I'm raising it again separately in this issue.

### Is your proposal related to a problem?

Wagtail provides a `purge_revisions` management command to delete stale `Revision` objects from the database. This command is aware of revisions that are still in use by Wagtail's workflow feature and avoids deleting those revisions ([link to source](https://github.com/wagtail/wagtail/blob/3d0282573d0d23f9eabe2fb6427e58b01f79ffe0/wagtail/management/commands/purge_revisions.py#L61-L66)).

However, it is somewhat common for third-party packages to rely on a specific revision. [wagtail-ab-testing](https://github.com/wagtail-nest/wagtail-ab-testing/blob/d04bd0032c4335a839c781851b12fe16037950de/wagtail_ab_testing/models.py#L76) is a real world example of this. It maintains a ForeignKey to a specific revision to track an AB experiment. If `purge_revisions` were to remove a revision in use by wagtail-ab-testing, experiment data loss would occur.

Data loss can be prevented by specifying the ForeignKey with `on_delete=models.PROTECT` but `purge_revisions` wouldn't be able to handle this and crash with a `ProtectedError`. This means third-party packages have no recourse: either they allow the revisions they depend on to be deleted or they mark their relations as protected and break Wagtails `purge_revisions` command.

### Describe the solution you'd like

I propose `purge_revisions` gracefully recovers from `ProtectedError` when trying to delete a revision. I also propose we document that relations to a `Revision` should be marked with `on_delete=models.PROTECT` if data loss is not desirable.

I also feel like the `purge_revisions` command should report the number of revisions that it couldn't delete because they were protected.

### Describe alternatives you've considered

An alternative mechanism I considered would be an API that allows packages to inform Wagtail which revisions they still use. The `purge_revisions` command can take this information into account and avoid revisions still in use.

This is a more complex solution with no apparent benefit over the solution described above.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `wagtail/management/commands/purge_revisions.py`
Content:
```
1 from django.conf import settings
2 from django.core.management.base import BaseCommand
3 from django.db.models import Q
4 from django.utils import timezone
5 
6 from wagtail.models import Revision, WorkflowState
7 
8 
9 class Command(BaseCommand):
10     help = "Delete revisions which are not the latest revision, published or scheduled to be published, or in moderation"
11 
12     def add_arguments(self, parser):
13         parser.add_argument(
14             "--days",
15             type=int,
16             help="Only delete revisions older than this number of days",
17         )
18         parser.add_argument(
19             "--pages",
20             action="store_true",
21             help="Only delete revisions of page models",
22         )
23         parser.add_argument(
24             "--non-pages",
25             action="store_true",
26             help="Only delete revisions of non-page models",
27         )
28 
29     def handle(self, *args, **options):
30         days = options.get("days")
31         pages = options.get("pages")
32         non_pages = options.get("non_pages")
33 
34         revisions_deleted = purge_revisions(days=days, pages=pages, non_pages=non_pages)
35 
36         if revisions_deleted:
37             self.stdout.write(
38                 self.style.SUCCESS(
39                     "Successfully deleted %s revisions" % revisions_deleted
40                 )
41             )
42         else:
43             self.stdout.write("No revisions deleted")
44 
45 
46 def purge_revisions(days=None, pages=True, non_pages=True):
47     if pages == non_pages:
48         # If both are True or both are False, purge revisions of pages and non-pages
49         objects = Revision.objects.all()
50     elif pages:
51         objects = Revision.objects.page_revisions()
52     elif non_pages:
53         objects = Revision.objects.not_page_revisions()
54 
55     # exclude revisions which have been submitted for moderation in the old system
56     # RemovedInWagtail60Warning
57     # Remove this when the deprecation period for the legacy
58     # moderation system ends.
59     purgeable_revisions = objects.exclude(submitted_for_moderation=True).exclude(
60         # and exclude revisions with an approved_go_live_at date
61         approved_go_live_at__isnull=False
62     )
63 
64     if getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
65         purgeable_revisions = purgeable_revisions.exclude(
66             # and exclude revisions linked to an in progress or needs changes workflow state
67             Q(task_states__workflow_state__status=WorkflowState.STATUS_IN_PROGRESS)
68             | Q(task_states__workflow_state__status=WorkflowState.STATUS_NEEDS_CHANGES)
69         )
70 
71     if days:
72         purgeable_until = timezone.now() - timezone.timedelta(days=days)
73         # only include revisions which were created before the cut off date
74         purgeable_revisions = purgeable_revisions.filter(created_at__lt=purgeable_until)
75 
76     deleted_revisions_count = 0
77 
78     for revision in purgeable_revisions.iterator():
79         # don't delete the latest revision
80         if not revision.is_latest_revision():
81             revision.delete()
82             deleted_revisions_count += 1
83 
84     return deleted_revisions_count
85
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/wagtail/management/commands/purge_revisions.py b/wagtail/management/commands/purge_revisions.py
--- a/wagtail/management/commands/purge_revisions.py
+++ b/wagtail/management/commands/purge_revisions.py
@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.core.management.base import BaseCommand
 from django.db.models import Q
+from django.db.models.deletion import ProtectedError
 from django.utils import timezone
 
 from wagtail.models import Revision, WorkflowState
@@ -31,7 +32,9 @@
         pages = options.get("pages")
         non_pages = options.get("non_pages")
 
-        revisions_deleted = purge_revisions(days=days, pages=pages, non_pages=non_pages)
+        revisions_deleted, protected_error_count = purge_revisions(
+            days=days, pages=pages, non_pages=non_pages
+        )
 
         if revisions_deleted:
             self.stdout.write(
@@ -39,6 +42,12 @@
                     "Successfully deleted %s revisions" % revisions_deleted
                 )
             )
+            self.stdout.write(
+                self.style.SUCCESS(
+                    "Ignored %s revisions because one or more protected relations exist that prevent deletion."
+                    % protected_error_count
+                )
+            )
         else:
             self.stdout.write("No revisions deleted")
 
@@ -74,11 +83,15 @@
         purgeable_revisions = purgeable_revisions.filter(created_at__lt=purgeable_until)
 
     deleted_revisions_count = 0
+    protected_error_count = 0
 
     for revision in purgeable_revisions.iterator():
         # don't delete the latest revision
         if not revision.is_latest_revision():
-            revision.delete()
-            deleted_revisions_count += 1
+            try:
+                revision.delete()
+                deleted_revisions_count += 1
+            except ProtectedError:
+                protected_error_count += 1
 
-    return deleted_revisions_count
+    return deleted_revisions_count, protected_error_count

{"golden_diff": "diff --git a/wagtail/management/commands/purge_revisions.py b/wagtail/management/commands/purge_revisions.py\n--- a/wagtail/management/commands/purge_revisions.py\n+++ b/wagtail/management/commands/purge_revisions.py\n@@ -1,6 +1,7 @@\n from django.conf import settings\n from django.core.management.base import BaseCommand\n from django.db.models import Q\n+from django.db.models.deletion import ProtectedError\n from django.utils import timezone\n \n from wagtail.models import Revision, WorkflowState\n@@ -31,7 +32,9 @@\n         pages = options.get(\"pages\")\n         non_pages = options.get(\"non_pages\")\n \n-        revisions_deleted = purge_revisions(days=days, pages=pages, non_pages=non_pages)\n+        revisions_deleted, protected_error_count = purge_revisions(\n+            days=days, pages=pages, non_pages=non_pages\n+        )\n \n         if revisions_deleted:\n             self.stdout.write(\n@@ -39,6 +42,12 @@\n                     \"Successfully deleted %s revisions\" % revisions_deleted\n                 )\n             )\n+            self.stdout.write(\n+                self.style.SUCCESS(\n+                    \"Ignored %s revisions because one or more protected relations exist that prevent deletion.\"\n+                    % protected_error_count\n+                )\n+            )\n         else:\n             self.stdout.write(\"No revisions deleted\")\n \n@@ -74,11 +83,15 @@\n         purgeable_revisions = purgeable_revisions.filter(created_at__lt=purgeable_until)\n \n     deleted_revisions_count = 0\n+    protected_error_count = 0\n \n     for revision in purgeable_revisions.iterator():\n         # don't delete the latest revision\n         if not revision.is_latest_revision():\n-            revision.delete()\n-            deleted_revisions_count += 1\n+            try:\n+                revision.delete()\n+                deleted_revisions_count += 1\n+            except ProtectedError:\n+                protected_error_count += 1\n \n-    return deleted_revisions_count\n+    return deleted_revisions_count, protected_error_count\n", "issue": "Avoid purging Revisions in use by third-party packages\nI originally raised this as part of some rework on the `purge_revisions` management command in https://github.com/wagtail/wagtail/pull/10619#pullrequestreview-1528373346. We considered it out of scope to fix the issue in that PR since Wagtail has always worked like this. Hence I'm raising it again separately in this issue.\r\n\r\n### Is your proposal related to a problem?\r\n\r\nWagtail provides a `purge_revisions` management command to delete stale `Revision` objects from the database. This command is aware of revisions that are still in use by Wagtail's workflow feature and avoids deleting those revisions ([link to source](https://github.com/wagtail/wagtail/blob/3d0282573d0d23f9eabe2fb6427e58b01f79ffe0/wagtail/management/commands/purge_revisions.py#L61-L66)).\r\n\r\nHowever, it is somewhat common for third-party packages to rely on a specific revision. [wagtail-ab-testing](https://github.com/wagtail-nest/wagtail-ab-testing/blob/d04bd0032c4335a839c781851b12fe16037950de/wagtail_ab_testing/models.py#L76) is a real world example of this. It maintains a ForeignKey to a specific revision to track an AB experiment. If `purge_revisions` were to remove a revision in use by wagtail-ab-testing, experiment data loss would occur.\r\n\r\nData loss can be prevented by specifying the ForeignKey with `on_delete=models.PROTECT` but `purge_revisions` wouldn't be able to handle this and crash with a `ProtectedError`. This means third-party packages have no recourse: either they allow the revisions they depend on to be deleted or they mark their relations as protected and break Wagtails `purge_revisions` command.\r\n\r\n### Describe the solution you'd like\r\n\r\nI propose `purge_revisions` gracefully recovers from `ProtectedError` when trying to delete a revision. I also propose we document that relations to a `Revision` should be marked with `on_delete=models.PROTECT` if data loss is not desirable.\r\n\r\nI also feel like the `purge_revisions` command should report the number of revisions that it couldn't delete because they were protected.\r\n\r\n### Describe alternatives you've considered\r\n\r\nAn alternative mechanism I considered would be an API that allows packages to inform Wagtail which revisions they still use. The `purge_revisions` command can take this information into account and avoid revisions still in use.\r\n\r\nThis is a more complex solution with no apparent benefit over the solution described above.\nAvoid purging Revisions in use by third-party packages\nI originally raised this as part of some rework on the `purge_revisions` management command in https://github.com/wagtail/wagtail/pull/10619#pullrequestreview-1528373346. We considered it out of scope to fix the issue in that PR since Wagtail has always worked like this. Hence I'm raising it again separately in this issue.\r\n\r\n### Is your proposal related to a problem?\r\n\r\nWagtail provides a `purge_revisions` management command to delete stale `Revision` objects from the database. This command is aware of revisions that are still in use by Wagtail's workflow feature and avoids deleting those revisions ([link to source](https://github.com/wagtail/wagtail/blob/3d0282573d0d23f9eabe2fb6427e58b01f79ffe0/wagtail/management/commands/purge_revisions.py#L61-L66)).\r\n\r\nHowever, it is somewhat common for third-party packages to rely on a specific revision. [wagtail-ab-testing](https://github.com/wagtail-nest/wagtail-ab-testing/blob/d04bd0032c4335a839c781851b12fe16037950de/wagtail_ab_testing/models.py#L76) is a real world example of this. It maintains a ForeignKey to a specific revision to track an AB experiment. If `purge_revisions` were to remove a revision in use by wagtail-ab-testing, experiment data loss would occur.\r\n\r\nData loss can be prevented by specifying the ForeignKey with `on_delete=models.PROTECT` but `purge_revisions` wouldn't be able to handle this and crash with a `ProtectedError`. This means third-party packages have no recourse: either they allow the revisions they depend on to be deleted or they mark their relations as protected and break Wagtails `purge_revisions` command.\r\n\r\n### Describe the solution you'd like\r\n\r\nI propose `purge_revisions` gracefully recovers from `ProtectedError` when trying to delete a revision. I also propose we document that relations to a `Revision` should be marked with `on_delete=models.PROTECT` if data loss is not desirable.\r\n\r\nI also feel like the `purge_revisions` command should report the number of revisions that it couldn't delete because they were protected.\r\n\r\n### Describe alternatives you've considered\r\n\r\nAn alternative mechanism I considered would be an API that allows packages to inform Wagtail which revisions they still use. The `purge_revisions` command can take this information into account and avoid revisions still in use.\r\n\r\nThis is a more complex solution with no apparent benefit over the solution described above.\n", "before_files": [{"content": "from django.conf import settings\nfrom django.core.management.base import BaseCommand\nfrom django.db.models import Q\nfrom django.utils import timezone\n\nfrom wagtail.models import Revision, WorkflowState\n\n\nclass Command(BaseCommand):\n    help = \"Delete revisions which are not the latest revision, published or scheduled to be published, or in moderation\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\n            \"--days\",\n            type=int,\n            help=\"Only delete revisions older than this number of days\",\n        )\n        parser.add_argument(\n            \"--pages\",\n            action=\"store_true\",\n            help=\"Only delete revisions of page models\",\n        )\n        parser.add_argument(\n            \"--non-pages\",\n            action=\"store_true\",\n            help=\"Only delete revisions of non-page models\",\n        )\n\n    def handle(self, *args, **options):\n        days = options.get(\"days\")\n        pages = options.get(\"pages\")\n        non_pages = options.get(\"non_pages\")\n\n        revisions_deleted = purge_revisions(days=days, pages=pages, non_pages=non_pages)\n\n        if revisions_deleted:\n            self.stdout.write(\n                self.style.SUCCESS(\n                    \"Successfully deleted %s revisions\" % revisions_deleted\n                )\n            )\n        else:\n            self.stdout.write(\"No revisions deleted\")\n\n\ndef purge_revisions(days=None, pages=True, non_pages=True):\n    if pages == non_pages:\n        # If both are True or both are False, purge revisions of pages and non-pages\n        objects = Revision.objects.all()\n    elif pages:\n        objects = Revision.objects.page_revisions()\n    elif non_pages:\n        objects = Revision.objects.not_page_revisions()\n\n    # exclude revisions which have been submitted for moderation in the old system\n    # RemovedInWagtail60Warning\n    # Remove this when the deprecation period for the legacy\n    # moderation system ends.\n    purgeable_revisions = objects.exclude(submitted_for_moderation=True).exclude(\n        # and exclude revisions with an approved_go_live_at date\n        approved_go_live_at__isnull=False\n    )\n\n    if getattr(settings, \"WAGTAIL_WORKFLOW_ENABLED\", True):\n        purgeable_revisions = purgeable_revisions.exclude(\n            # and exclude revisions linked to an in progress or needs changes workflow state\n            Q(task_states__workflow_state__status=WorkflowState.STATUS_IN_PROGRESS)\n            | Q(task_states__workflow_state__status=WorkflowState.STATUS_NEEDS_CHANGES)\n        )\n\n    if days:\n        purgeable_until = timezone.now() - timezone.timedelta(days=days)\n        # only include revisions which were created before the cut off date\n        purgeable_revisions = purgeable_revisions.filter(created_at__lt=purgeable_until)\n\n    deleted_revisions_count = 0\n\n    for revision in purgeable_revisions.iterator():\n        # don't delete the latest revision\n        if not revision.is_latest_revision():\n            revision.delete()\n            deleted_revisions_count += 1\n\n    return deleted_revisions_count\n", "path": "wagtail/management/commands/purge_revisions.py"}], "after_files": [{"content": "from django.conf import settings\nfrom django.core.management.base import BaseCommand\nfrom django.db.models import Q\nfrom django.db.models.deletion import ProtectedError\nfrom django.utils import timezone\n\nfrom wagtail.models import Revision, WorkflowState\n\n\nclass Command(BaseCommand):\n    help = \"Delete revisions which are not the latest revision, published or scheduled to be published, or in moderation\"\n\n    def add_arguments(self, parser):\n        parser.add_argument(\n            \"--days\",\n            type=int,\n            help=\"Only delete revisions older than this number of days\",\n        )\n        parser.add_argument(\n            \"--pages\",\n            action=\"store_true\",\n            help=\"Only delete revisions of page models\",\n        )\n        parser.add_argument(\n            \"--non-pages\",\n            action=\"store_true\",\n            help=\"Only delete revisions of non-page models\",\n        )\n\n    def handle(self, *args, **options):\n        days = options.get(\"days\")\n        pages = options.get(\"pages\")\n        non_pages = options.get(\"non_pages\")\n\n        revisions_deleted, protected_error_count = purge_revisions(\n            days=days, pages=pages, non_pages=non_pages\n        )\n\n        if revisions_deleted:\n            self.stdout.write(\n                self.style.SUCCESS(\n                    \"Successfully deleted %s revisions\" % revisions_deleted\n                )\n            )\n            self.stdout.write(\n                self.style.SUCCESS(\n                    \"Ignored %s revisions because one or more protected relations exist that prevent deletion.\"\n                    % protected_error_count\n                )\n            )\n        else:\n            self.stdout.write(\"No revisions deleted\")\n\n\ndef purge_revisions(days=None, pages=True, non_pages=True):\n    if pages == non_pages:\n        # If both are True or both are False, purge revisions of pages and non-pages\n        objects = Revision.objects.all()\n    elif pages:\n        objects = Revision.objects.page_revisions()\n    elif non_pages:\n        objects = Revision.objects.not_page_revisions()\n\n    # exclude revisions which have been submitted for moderation in the old system\n    # RemovedInWagtail60Warning\n    # Remove this when the deprecation period for the legacy\n    # moderation system ends.\n    purgeable_revisions = objects.exclude(submitted_for_moderation=True).exclude(\n        # and exclude revisions with an approved_go_live_at date\n        approved_go_live_at__isnull=False\n    )\n\n    if getattr(settings, \"WAGTAIL_WORKFLOW_ENABLED\", True):\n        purgeable_revisions = purgeable_revisions.exclude(\n            # and exclude revisions linked to an in progress or needs changes workflow state\n            Q(task_states__workflow_state__status=WorkflowState.STATUS_IN_PROGRESS)\n            | Q(task_states__workflow_state__status=WorkflowState.STATUS_NEEDS_CHANGES)\n        )\n\n    if days:\n        purgeable_until = timezone.now() - timezone.timedelta(days=days)\n        # only include revisions which were created before the cut off date\n        purgeable_revisions = purgeable_revisions.filter(created_at__lt=purgeable_until)\n\n    deleted_revisions_count = 0\n    protected_error_count = 0\n\n    for revision in purgeable_revisions.iterator():\n        # don't delete the latest revision\n        if not revision.is_latest_revision():\n            try:\n                revision.delete()\n                deleted_revisions_count += 1\n            except ProtectedError:\n                protected_error_count += 1\n\n    return deleted_revisions_count, protected_error_count\n", "path": "wagtail/management/commands/purge_revisions.py"}]}