diff --git a/docs/reference/pages/model_reference.rst b/docs/reference/pages/model_reference.rst index b6c83263ad..2d6d41645c 100644 --- a/docs/reference/pages/model_reference.rst +++ b/docs/reference/pages/model_reference.rst @@ -267,6 +267,8 @@ In addition to the model fields provided, ``Page`` has many properties and metho .. automethod:: create_alias + .. automethod:: update_aliases + .. autoattribute:: has_workflow .. automethod:: get_workflow diff --git a/wagtail/core/models.py b/wagtail/core/models.py index b5c3dcd46f..1cb8ed05c9 100644 --- a/wagtail/core/models.py +++ b/wagtail/core/models.py @@ -1218,6 +1218,13 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): :param clean: Set this to False to skip cleaning page content before saving this revision :return: the newly created revision """ + # Raise an error if this page is an alias. + if self.alias_of_id: + raise RuntimeError( + "save_revision() was called on an alias page. " + "Revisions are not required for alias pages as they are an exact copy of another page." + ) + if clean: self.full_clean() @@ -1296,6 +1303,108 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): else: return self.specific + def update_aliases(self, *, revision=None, user=None, _content_json=None, _updated_ids=None): + """ + Publishes all aliases that follow this page with the latest content from this page. + + This is called by Wagtail whenever a page with aliases is published. + + :param revision: The revision of the original page that we are updating to (used for logging purposes) + :type revision: PageRevision, optional + :param user: The user who is publishing (used for logging purposes) + :type user: User, optional + """ + specific_self = self.specific + + # Only compute this if necessary since it's quite a heavy operation + if _content_json is None: + _content_json = self.to_json() + + # A list of IDs that have already been updated. This is just in case someone has + # created an alias loop (which is impossible to do with the UI Wagtail provides) + _updated_ids = _updated_ids or [] + + for alias in self.specific_class.objects.filter(alias_of=self).exclude(id__in=_updated_ids): + # FIXME: Switch to the same fields that are excluded from copy + # We can't do this right now because we can't exclude fields from with_content_json + exclude_fields = ['id', 'path', 'depth', 'numchild', 'url_path', 'path', 'index_entries'] + + # Copy field content + alias_updated = alias.with_content_json(_content_json) + + # Copy child relations + child_object_map = specific_self.copy_all_child_relations(target=alias_updated, exclude=exclude_fields) + + # Process child objects + # This has two jobs: + # - If the alias is in a different locale, this updates the + # locale of any translatable child objects to match + # - If the alias is not a translation of the original, this + # changes the translation_key field of all child objects + # so they do not clash + if child_object_map: + alias_is_translation = alias.translation_key == self.translation_key + + def process_child_object(child_object): + if isinstance(child_object, TranslatableMixin): + # Child object's locale must always match the page + child_object.locale = alias_updated.locale + + # If the alias isn't a translation of the original page, + # change the child object's translation_keys so they are + # not either + if not alias_is_translation: + child_object.translation_key = uuid.uuid4() + + for (rel, previous_id), child_objects in child_object_map.items(): + if previous_id is None: + for child_object in child_objects: + process_child_object(child_object) + else: + process_child_object(child_objects) + + # Copy M2M relations + _copy_m2m_relations(specific_self, alias_updated, exclude_fields=exclude_fields) + + # Don't change the aliases slug + # Aliases can have their own slugs so they can be siblings of the original + alias_updated.slug = alias.slug + alias_updated.set_url_path(alias_updated.get_parent()) + + # Aliases don't have revisions, so update fields that would normally be updated by save_revision + alias_updated.draft_title = alias_updated.title + alias_updated.latest_revision_created_at = self.latest_revision_created_at + + alias_updated.save(clean=False) + + page_published.send(sender=alias_updated.specific_class, instance=alias_updated, revision=revision, alias=True) + + # Log the publish of the alias + PageLogEntry.objects.log_action( + instance=alias_updated, + action='wagtail.publish', + user=user, + ) + + # Update any aliases of that alias + + # Design note: + # It could be argued that this will be faster if we just changed these alias-of-alias + # pages to all point to the original page and avoid having to update them recursively. + # + # But, it's useful to have a record of how aliases have been chained. + # For example, In Wagtail Localize, we use aliases to create mirrored trees, but those + # trees themselves could have aliases within them. If an alias within a tree is + # converted to a regular page, we want the alias in the mirrored tree to follow that + # new page and stop receiving updates from the original page. + # + # Doing it this way requires an extra lookup query per alias but this is small in + # comparison to the work required to update the alias. + + alias.update_aliases(revision=revision, _content_json=_content_json, _updated_ids=_updated_ids) + + update_aliases.alters_data = True + def unpublish(self, set_expired=False, commit=True, user=None, log_action=True): """ Unpublish the page by setting ``live`` to ``False``. Does nothing if ``live`` is already ``False`` @@ -1327,6 +1436,10 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): self.revisions.update(approved_go_live_at=None) + # Unpublish aliases + for alias in self.aliases.all(): + alias.unpublish() + context_object_name = None def get_context(self, request, *args, **kwargs): @@ -2695,6 +2808,9 @@ class PageRevision(models.Model): if page.live: page_published.send(sender=page.specific_class, instance=page.specific, revision=self) + # Update alias pages + page.update_aliases(revision=self, user=user, _content_json=self.content_json) + if log_action: data = None if previous_revision: diff --git a/wagtail/core/tests/test_page_model.py b/wagtail/core/tests/test_page_model.py index 01cde949eb..af0a1e493b 100644 --- a/wagtail/core/tests/test_page_model.py +++ b/wagtail/core/tests/test_page_model.py @@ -17,7 +17,7 @@ from django.utils import timezone, translation from freezegun import freeze_time from wagtail.core.models import ( - Locale, Page, PageManager, ParentNotTranslatedError, Site, get_page_models, get_translatable_models) + Locale, Page, PageLogEntry, PageManager, ParentNotTranslatedError, Site, get_page_models, get_translatable_models) from wagtail.core.signals import page_published from wagtail.tests.testapp.models import ( AbstractPage, Advert, AlwaysShowInMenusPage, BlogCategory, BlogCategoryBlogPage, BusinessChild, @@ -1896,6 +1896,52 @@ class TestCreateAlias(TestCase): EventPage.exclude_fields_in_copy = [] +class TestUpdateAliases(TestCase): + fixtures = ['test.json'] + + def test_update_aliases(self): + event_page = EventPage.objects.get(url_path='/home/events/christmas/') + alias = event_page.create_alias(update_slug='new-event-page') + alias_alias = alias.create_alias(update_slug='new-event-page-2') + + # Update the title and add a speaker + event_page.title = "Updated title" + event_page.draft_title = "A different draft title" + event_page.speakers.add(EventPageSpeaker( + first_name="Ted", + last_name="Crilly", + )) + event_page.save() + + # Nothing should've happened yet + alias.refresh_from_db() + alias_alias.refresh_from_db() + self.assertEqual(alias.title, "Christmas") + self.assertEqual(alias_alias.title, "Christmas") + self.assertEqual(alias.speakers.count(), 1) + self.assertEqual(alias_alias.speakers.count(), 1) + + PageLogEntry.objects.all().delete() + + event_page.update_aliases() + + # Check that the aliases have been updated + alias.refresh_from_db() + alias_alias.refresh_from_db() + self.assertEqual(alias.title, "Updated title") + self.assertEqual(alias_alias.title, "Updated title") + self.assertEqual(alias.speakers.count(), 2) + self.assertEqual(alias_alias.speakers.count(), 2) + + # Draft titles shouldn't update as alias pages do not have drafts + self.assertEqual(alias.draft_title, "Updated title") + self.assertEqual(alias_alias.draft_title, "Updated title") + + # Check log entries were created + self.assertTrue(PageLogEntry.objects.filter(page=alias, action='wagtail.publish').exists()) + self.assertTrue(PageLogEntry.objects.filter(page=alias_alias, action='wagtail.publish').exists()) + + class TestCopyForTranslation(TestCase): fixtures = ['test.json'] @@ -2553,6 +2599,7 @@ class TestPageWithContentJSON(TestCase): class TestUnpublish(TestCase): + fixtures = ['test.json'] def test_unpublish_doesnt_call_full_clean_before_save(self): root_page = Page.objects.get(id=1) @@ -2564,6 +2611,30 @@ class TestUnpublish(TestCase): # This shouldn't fail with a ValidationError. home_page.unpublish() + def test_unpublish_also_unpublishes_aliases(self): + event_page = EventPage.objects.get(url_path='/home/events/christmas/') + alias = event_page.create_alias(update_slug='new-event-page') + alias_alias = alias.create_alias(update_slug='new-event-page-2') + + self.assertTrue(event_page.live) + self.assertTrue(alias.live) + self.assertTrue(alias_alias.live) + + PageLogEntry.objects.all().delete() + + # Unpublish the event page + event_page.unpublish() + + alias.refresh_from_db() + alias_alias.refresh_from_db() + self.assertFalse(event_page.live) + self.assertFalse(alias.live) + self.assertFalse(alias_alias.live) + + # Check log entries were created for the aliases + self.assertTrue(PageLogEntry.objects.filter(page=alias, action='wagtail.unpublish').exists()) + self.assertTrue(PageLogEntry.objects.filter(page=alias_alias, action='wagtail.unpublish').exists()) + class TestCachedContentType(TestCase): """Tests for Page.cached_content_type"""