Repository: wagtail/wagtail

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.


Discussion:
I discussed this approach with @laymonage and @gasman today and we feel the solution described is appropriate. The alternative considered is not an appropriate solution and must be ignored.

Tasks to fix this issue:
 - **Code**: wrap the call to `revision.delete()` in the purge_revisions command with try/except and make it ignore `ProtectedError`. That change would need to be made here: https://github.com/wagtail/wagtail/blob/main/wagtail/management/commands/purge_revisions.py#L78-L79
 - **Code**: create and increment an counter every time a ProtectedError occurs and display this counter when the command finishes.
 - **Tests:** write an unit test that proves the purge_revisions command won't crash when a ProtectedError occurs. Here is a good place to add a new test: https://github.com/wagtail/wagtail/blob/dec8ec47ed45560d575d03365ca3198162f171a9/wagtail/tests/test_management_commands.py#L619 _I understand writing a unit test may be difficult for people new to coding but I encourage you to try!_
 - **Docs**: document that purge_revisions won't delete revisions that are protected here: https://docs.wagtail.org/en/latest/reference/management_commands.html#purge-revisions
 - **Docs**: add a note to the [revision reference documentation](https://docs.wagtail.org/en/latest/reference/pages/model_reference.html#revision-model-ref) explaining that stale revisions may be deleted by the purge_revisions command. Also document that Wagtail implementors may want to use a ForeignKey with `on_delete=models.PROTECT` when referring to a specific revision they don't want to be deleted.

I encourage new contributors to try work on this issue, I hope the steps I described above help you. Don't forget to read the '[your first contribution](https://docs.wagtail.org/en/latest/contributing/first_contribution_guide.html)` guide in the docs!
Giving it a shot.
@disconnect821  Go ahead let me know if you need any help.
I'm starting to work on it.
I discussed this approach with @laymonage and @gasman today and we feel the solution described is appropriate. The alternative considered is not an appropriate solution and must be ignored.

Tasks to fix this issue:
 - **Code**: wrap the call to `revision.delete()` in the purge_revisions command with try/except and make it ignore `ProtectedError`. That change would need to be made here: https://github.com/wagtail/wagtail/blob/main/wagtail/management/commands/purge_revisions.py#L78-L79
 - **Code**: create and increment an counter every time a ProtectedError occurs and display this counter when the command finishes.
 - **Tests:** write an unit test that proves the purge_revisions command won't crash when a ProtectedError occurs. Here is a good place to add a new test: https://github.com/wagtail/wagtail/blob/dec8ec47ed45560d575d03365ca3198162f171a9/wagtail/tests/test_management_commands.py#L619 _I understand writing a unit test may be difficult for people new to coding but I encourage you to try!_
 - **Docs**: document that purge_revisions won't delete revisions that are protected here: https://docs.wagtail.org/en/latest/reference/management_commands.html#purge-revisions
 - **Docs**: add a note to the [revision reference documentation](https://docs.wagtail.org/en/latest/reference/pages/model_reference.html#revision-model-ref) explaining that stale revisions may be deleted by the purge_revisions command. Also document that Wagtail implementors may want to use a ForeignKey with `on_delete=models.PROTECT` when referring to a specific revision they don't want to be deleted.

I encourage new contributors to try work on this issue, I hope the steps I described above help you. Don't forget to read the '[your first contribution](https://docs.wagtail.org/en/latest/contributing/first_contribution_guide.html)` guide in the docs!
Giving it a shot.
@disconnect821  Go ahead let me know if you need any help.
I'm starting to work on it.

Resolving patch:
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 @@ def handle(self, *args, **options):
         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 @@ def handle(self, *args, **options):
                     "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 @@ def purge_revisions(days=None, pages=True, non_pages=True):
         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


Test patch:
diff --git a/wagtail/test/testapp/migrations/0029_variousondeletemodel_cascading_toy.py b/wagtail/test/testapp/migrations/0029_variousondeletemodel_cascading_toy.py
--- a/wagtail/test/testapp/migrations/0029_variousondeletemodel_cascading_toy.py
+++ b/wagtail/test/testapp/migrations/0029_variousondeletemodel_cascading_toy.py
@@ -5,7 +5,6 @@
 
 
 class Migration(migrations.Migration):
-
     dependencies = [
         ("tests", "0028_fullfeaturedsnippet_some_number"),
     ]
diff --git a/wagtail/test/testapp/migrations/0030_purgerevisionsprotectedtestmodel.py b/wagtail/test/testapp/migrations/0030_purgerevisionsprotectedtestmodel.py
new file mode 100644
--- /dev/null
+++ b/wagtail/test/testapp/migrations/0030_purgerevisionsprotectedtestmodel.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.0.10 on 2023-10-09 07:24
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("wagtailcore", "0089_log_entry_data_json_null_to_object"),
+        ("tests", "0029_variousondeletemodel_cascading_toy"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="PurgeRevisionsProtectedTestModel",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "revision",
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="+",
+                        to="wagtailcore.revision",
+                    ),
+                ),
+            ],
+        ),
+    ]
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
@@ -2181,3 +2181,9 @@ def is_cool(self):
 
     def __str__(self):
         return f"{self.name} ({self.release_date})"
+
+
+class PurgeRevisionsProtectedTestModel(models.Model):
+    revision = models.OneToOneField(
+        "wagtailcore.Revision", on_delete=models.PROTECT, related_name="+"
+    )
diff --git a/wagtail/tests/test_management_commands.py b/wagtail/tests/test_management_commands.py
--- a/wagtail/tests/test_management_commands.py
+++ b/wagtail/tests/test_management_commands.py
@@ -24,6 +24,7 @@
     DraftStateModel,
     EventPage,
     FullFeaturedSnippet,
+    PurgeRevisionsProtectedTestModel,
     SecretPage,
     SimplePage,
 )
@@ -167,7 +168,6 @@ def test_move_pages(self):
 
 
 class TestSetUrlPathsCommand(TestCase):
-
     fixtures = ["test.json"]
 
     def run_command(self):
@@ -728,6 +728,18 @@ def test_purge_revisions_with_date_cutoff(self):
         # revision is now older than 30 days, so should be deleted
         self.assertRevisionNotExists(old_revision)
 
+    def test_purge_revisions_protected_error(self):
+        revision_old = self.object.save_revision()
+        PurgeRevisionsProtectedTestModel.objects.create(revision=revision_old)
+        revision_purged = self.object.save_revision()
+        self.object.save_revision()
+
+        self.run_command()
+        # revision should not be deleted, as it is protected
+        self.assertRevisionExists(revision_old)
+        # Any other revisions are deleted
+        self.assertRevisionNotExists(revision_purged)
+
 
 class TestPurgeRevisionsCommandForSnippets(TestPurgeRevisionsCommandForPages):
     def get_object(self):