diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a4c8745f88..38b0f1879f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -22,6 +22,7 @@ Changelog * Add ability to replace the default Wagtail logo in the userbar, via `branding_logo` block (Meteor0id) * Remove sticky footer on small devices, so that content is not blocked and more easily editable (Saeed Tahmasebi) * Add ``alt`` property to ``ImageRenditionField`` api representation (Liam Mullens) + * Add ``purge_revisions`` management command to purge old page revisions (Jacob Topp-Mugglestone, Tom Dyson) * Fix: Support IPv6 domain (Alex Gleason, Coen van der Kamp) * Fix: Ensure link to add a new user works when no users are visible in the users list (LB (Ben Johnston)) * Fix: `AbstractEmailForm` saved submission fields are now aligned with the email content fields, `form.cleaned_data` will be used instead of `form.fields` (Haydn Greatnews) diff --git a/docs/reference/management_commands.rst b/docs/reference/management_commands.rst index 14fa1773ff..bafbb36f1f 100644 --- a/docs/reference/management_commands.rst +++ b/docs/reference/management_commands.rst @@ -48,6 +48,20 @@ Options: This is the **id** of the page to move pages to. +.. _purge_revisions: + +purge_revisions +--------------- + +.. code-block:: console + + $ manage.py purge_revisions [--days=] + +This command deletes old page revisions which are not in moderation, live, approved to go live, or the latest +revision for a page. If the ``days`` argument is supplied, only revisions older than the specified number of +days will be deleted. + + .. _update_index: update_index diff --git a/docs/releases/2.10.rst b/docs/releases/2.10.rst index 64d2144b32..3bd52bedce 100644 --- a/docs/releases/2.10.rst +++ b/docs/releases/2.10.rst @@ -31,6 +31,7 @@ Other features * Remove sticky footer on small devices, so that content is not blocked and more easily editable (Saeed Tahmasebi) * Add ability to replace the default Wagtail logo in the userbar, via ``branding_logo`` block (Meteor0id) * Add `alt` property to `ImageRenditionField` api representation (Liam Mullens) + * Add `purge_revisions` management command to purge old page revisions (Jacob Topp-Mugglestone, Tom Dyson) Bug fixes diff --git a/wagtail/core/management/commands/publish_scheduled_pages.py b/wagtail/core/management/commands/publish_scheduled_pages.py index cf99c7ade6..daf8e3a5f2 100644 --- a/wagtail/core/management/commands/publish_scheduled_pages.py +++ b/wagtail/core/management/commands/publish_scheduled_pages.py @@ -21,7 +21,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( '--dryrun', action='store_true', dest='dryrun', default=False, - help="Dry run -- dont't change anything.") + help="Dry run -- don't change anything.") def handle(self, *args, **options): dryrun = False diff --git a/wagtail/core/management/commands/purge_revisions.py b/wagtail/core/management/commands/purge_revisions.py new file mode 100644 index 0000000000..a455b40566 --- /dev/null +++ b/wagtail/core/management/commands/purge_revisions.py @@ -0,0 +1,57 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone + +from wagtail.core.models import PageRevision + +try: + from wagtail.core.models import WorkflowState + workflow_support = True +except ImportError: + workflow_support = False + + +class Command(BaseCommand): + help = 'Delete page revisions which are not the latest revision for a page, published or scheduled to be published, or in moderation' + + def add_arguments(self, parser): + parser.add_argument('--days', type=int, help="Only delete revisions older than this number of days") + + def handle(self, *args, **options): + days = options.get('days') + + revisions_deleted = purge_revisions(days=days) + + if revisions_deleted: + self.stdout.write(self.style.SUCCESS('Successfully deleted %s revisions' % revisions_deleted)) + else: + self.stdout.write("No revisions deleted") + + +def purge_revisions(days=None): + # exclude revisions which have been submitted for moderation in the old system + purgeable_revisions = PageRevision.objects.exclude( + submitted_for_moderation=True + ).exclude( + # and exclude revisions with an approved_go_live_at date + approved_go_live_at__isnull=False) + + if workflow_support: + purgeable_revisions = purgeable_revisions.exclude( + # and exclude revisions linked to an in progress workflow state + task_states__workflow_state__status=WorkflowState.STATUS_IN_PROGRESS + ) + + if days: + purgeable_until = timezone.now() - timezone.timedelta(days=days) + # only include revisions which were created before the cut off date + purgeable_revisions = purgeable_revisions.filter(created_at__lt=purgeable_until) + + deleted_revisions_count = 0 + + for revision in purgeable_revisions: + # don't delete the latest revision for any page + if not revision.is_latest_revision(): + revision.delete() + deleted_revisions_count += 1 + + return deleted_revisions_count diff --git a/wagtail/core/tests/test_management_commands.py b/wagtail/core/tests/test_management_commands.py index beec685ad8..1f58f386e0 100644 --- a/wagtail/core/tests/test_management_commands.py +++ b/wagtail/core/tests/test_management_commands.py @@ -1,6 +1,7 @@ from datetime import timedelta from io import StringIO +from django.contrib.auth import get_user_model from django.core import management from django.db import models from django.test import TestCase @@ -332,3 +333,92 @@ class TestPublishScheduledPagesCommand(TestCase): p = Page.objects.get(slug='hello-world') self.assertFalse(PageRevision.objects.filter(page=p, submitted_for_moderation=True).exists()) + + +class TestPurgeRevisionsCommand(TestCase): + fixtures = ['test.json'] + + def setUp(self): + # Find root page + self.root_page = Page.objects.get(id=2) + self.page = SimplePage( + title="Hello world!", + slug="hello-world", + content="hello", + live=False, + ) + self.root_page.add_child(instance=self.page) + self.page.refresh_from_db() + + def run_command(self, days=None): + if days: + days_input = '--days=' + str(days) + return management.call_command('purge_revisions', days_input, stdout=StringIO()) + return management.call_command('purge_revisions', stdout=StringIO()) + + def test_latest_revision_not_purged(self): + + revision_1 = self.page.save_revision() + + revision_2 = self.page.save_revision() + + self.run_command() + + # revision 1 should be deleted, revision 2 should not be + self.assertNotIn(revision_1, PageRevision.objects.filter(page=self.page)) + self.assertIn(revision_2, PageRevision.objects.filter(page=self.page)) + + + def test_revisions_in_moderation_not_purged(self): + + self.page.save_revision(submitted_for_moderation=True) + + revision = self.page.save_revision() + + self.run_command() + + self.assertTrue(PageRevision.objects.filter(page=self.page, submitted_for_moderation=True).exists()) + + try: + from wagtail.core.models import Task, Workflow, WorkflowTask + workflow = Workflow.objects.create(name='test_workflow') + task_1 = Task.objects.create(name='test_task_1') + user = get_user_model().objects.first() + WorkflowTask.objects.create(workflow=workflow, task=task_1, sort_order=1) + workflow.start(self.page, user) + self.page.save_revision() + self.run_command() + # even though no longer the latest revision, the old revision should stay as it is + # attached to an in progress workflow + self.assertIn(revision, PageRevision.objects.filter(page=self.page)) + except ImportError: + pass + + def test_revisions_with_approve_go_live_not_purged(self): + + approved_revision = self.page.save_revision(approved_go_live_at=timezone.now() + timedelta(days=1)) + + self.page.save_revision() + + self.run_command() + + self.assertIn(approved_revision, PageRevision.objects.filter(page=self.page)) + + def test_purge_revisions_with_date_cutoff(self): + + old_revision = self.page.save_revision() + + self.page.save_revision() + + self.run_command(days=30) + + # revision should not be deleted, as it is younger than 30 days + self.assertIn(old_revision, PageRevision.objects.filter(page=self.page)) + + old_revision.created_at = timezone.now() - timedelta(days=31) + old_revision.save() + + self.run_command(days=30) + + # revision is now older than 30 days, so should be deleted + self.assertNotIn(old_revision, PageRevision.objects.filter(page=self.page))