diff --git a/docs/reference/pages/model_reference.rst b/docs/reference/pages/model_reference.rst index f5a2dd84fd..b6c83263ad 100644 --- a/docs/reference/pages/model_reference.rst +++ b/docs/reference/pages/model_reference.rst @@ -265,6 +265,8 @@ In addition to the model fields provided, ``Page`` has many properties and metho .. automethod:: save + .. automethod:: create_alias + .. autoattribute:: has_workflow .. automethod:: get_workflow diff --git a/wagtail/admin/wagtail_hooks.py b/wagtail/admin/wagtail_hooks.py index f1e045d934..a5c30a4b23 100644 --- a/wagtail/admin/wagtail_hooks.py +++ b/wagtail/admin/wagtail_hooks.py @@ -841,6 +841,14 @@ def register_core_log_actions(actions): except KeyError: return _("Copied") + def create_alias_message(data): + try: + return _('Created an alias of %(title)s') % { + 'title': data['source']['title'], + } + except KeyError: + return _("Created an alias") + def move_message(data): try: return _("Moved from '%(old_parent)s' to '%(new_parent)s'") % { @@ -916,6 +924,7 @@ def register_core_log_actions(actions): actions.register_action('wagtail.rename', _('Rename'), rename_message) actions.register_action('wagtail.revert', _('Revert'), revert_message) actions.register_action('wagtail.copy', _('Copy'), copy_message) + actions.register_action('wagtail.create_alias', _('Create alias'), create_alias_message) actions.register_action('wagtail.move', _('Move'), move_message) actions.register_action('wagtail.publish.schedule', _("Schedule publication"), schedule_publish_message) actions.register_action('wagtail.schedule.cancel', _("Unschedule publication"), unschedule_publish_message) diff --git a/wagtail/core/models.py b/wagtail/core/models.py index 5e6408a74f..83bd7d083c 100644 --- a/wagtail/core/models.py +++ b/wagtail/core/models.py @@ -1940,6 +1940,147 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): copy.alters_data = True + def create_alias(self, *, recursive=False, parent=None, update_slug=None, update_locale=None, user=None, log_action='wagtail.create_alias', reset_translation_key=True, _mpnode_attrs=None): + """ + Creates an alias of the given page. + + An alias is like a copy, but an alias remains in sync with the original page. They + are not directly editable and do not have revisions. + + You can convert an alias into a regular page by setting the .alias_of attibute to None + and creating an initial revision. + + :param recursive: create aliases of the page's subtree, defaults to False + :type recursive: boolean, optional + :param parent: The page to create the new alias under + :type parent: Page, optional + :param update_slug: The slug of the new alias page, defaults to the slug of the original page + :type update_slug: string, optional + :param update_locale: The locale of the new alias page, defaults to the locale of the original page + :type update_locale: Locale, optional + :param user: The user who is performing this action. This user would be assigned as the owner of the new page and appear in the audit log + :type user: User, optional + :param log_action: Override the log action with a custom one. or pass None to skip logging, defaults to 'wagtail.create_alias' + :type log_action: string or None, optional + :param reset_translation_key: Generate new translation_keys for the page and any translatable child objects, defaults to False + :type reset_translation_key: boolean, optional + """ + specific_self = self.specific + + # 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 + # which we use for updating aliases + exclude_fields = ['id', 'path', 'depth', 'numchild', 'url_path', 'path', 'index_entries'] + + update_attrs = { + 'alias_of': self, + + # Aliases don't have revisions so the draft title should always match the live title + 'draft_title': self.title, + + # Likewise, an alias page can't have unpublished changes if it's live + 'has_unpublished_changes': not self.live, + } + + if update_slug: + update_attrs['slug'] = update_slug + + if update_locale: + update_attrs['locale'] = update_locale + + if user: + update_attrs['owner'] = user + + # When we're not copying for translation, we should give the translation_key a new value + if reset_translation_key: + update_attrs['translation_key'] = uuid.uuid4() + + alias, child_object_map = _copy(specific_self, update_attrs=update_attrs, exclude_fields=exclude_fields) + + # Update any translatable child objects + for (child_relation, old_pk), child_object in child_object_map.items(): + if isinstance(child_object, TranslatableMixin): + if update_locale: + child_object.locale = update_locale + + # When we're not copying for translation, we should give the translation_key a new value for each child object as well + if reset_translation_key: + child_object.translation_key = uuid.uuid4() + + # Save the new page + if _mpnode_attrs: + # We've got a tree position already reserved. Perform a quick save + alias.path = _mpnode_attrs[0] + alias.depth = _mpnode_attrs[1] + alias.save(clean=False) + + else: + if parent: + if recursive and (parent == self or parent.is_descendant_of(self)): + raise Exception("You cannot copy a tree branch recursively into itself") + alias = parent.add_child(instance=alias) + else: + alias = self.add_sibling(instance=alias) + + _mpnode_attrs = (alias.path, alias.depth) + + _copy_m2m_relations(specific_self, alias, exclude_fields=exclude_fields) + + # Log + if log_action: + source_parent = specific_self.get_parent() + PageLogEntry.objects.log_action( + instance=alias, + action=log_action, + user=user, + data={ + 'page': { + 'id': alias.id, + 'title': alias.get_admin_display_title() + }, + 'source': {'id': source_parent.id, 'title': source_parent.get_admin_display_title()} if source_parent else None, + 'destination': {'id': parent.id, 'title': parent.get_admin_display_title()} if parent else None, + }, + ) + if alias.live: + # Log the publish + PageLogEntry.objects.log_action( + instance=alias, + action='wagtail.publish', + user=user, + ) + + logger.info("Page alias created: \"%s\" id=%d from=%d", alias.title, alias.id, self.id) + + # Copy child pages + if recursive: + numchild = 0 + + for child_page in self.get_children().specific(): + newdepth = _mpnode_attrs[1] + 1 + child_mpnode_attrs = ( + Page._get_path(_mpnode_attrs[0], newdepth, numchild), + newdepth + ) + numchild += 1 + child_page.create_alias( + recursive=True, + parent=alias, + update_locale=update_locale, + user=user, + log_action=log_action, + reset_translation_key=reset_translation_key, + _mpnode_attrs=child_mpnode_attrs + ) + + if numchild > 0: + alias.numchild = numchild + alias.save(clean=False, update_fields=['numchild']) + + return alias + + create_alias.alters_data = True + @transaction.atomic def copy_for_translation(self, locale, copy_parents=False, exclude_fields=None): """ diff --git a/wagtail/core/tests/test_page_model.py b/wagtail/core/tests/test_page_model.py index d6e6148687..a92acfd25e 100644 --- a/wagtail/core/tests/test_page_model.py +++ b/wagtail/core/tests/test_page_model.py @@ -1,5 +1,6 @@ import datetime import json +import unittest from unittest.mock import Mock import pytz @@ -1545,6 +1546,356 @@ class TestCopyPage(TestCase): self.assertFalse(signal_fired) +class TestCreateAlias(TestCase): + fixtures = ['test.json'] + + def test_create_alias(self): + about_us = SimplePage.objects.get(url_path='/home/about-us/') + + # Set a different draft title, aliases are not supposed to + # have a different draft_title because they don't have revisions. + # This should be corrected when copying + about_us.draft_title = 'Draft title' + about_us.save(update_fields=['draft_title']) + + # Copy it + new_about_us = about_us.create_alias(update_slug='new-about-us') + + # Check that new_about_us is correct + self.assertIsInstance(new_about_us, SimplePage) + self.assertEqual(new_about_us.slug, 'new-about-us') + # Draft title should be changed to match the live title + self.assertEqual(new_about_us.draft_title, 'About us') + + # Check that new_about_us is a different page + self.assertNotEqual(about_us.id, new_about_us.id) + + # Check that the url path was updated + self.assertEqual(new_about_us.url_path, '/home/new-about-us/') + + # Check that the alias_of field was filled in + self.assertEqual(new_about_us.alias_of, about_us) + + def test_create_alias_copies_child_objects(self): + christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') + + # Copy it + new_christmas_event = christmas_event.create_alias(update_slug='new-christmas-event') + + # Check that the speakers were copied + self.assertEqual(new_christmas_event.speakers.count(), 1, "Child objects weren't copied") + + # Check that the speakers weren't removed from old page + self.assertEqual(christmas_event.speakers.count(), 1, "Child objects were removed from the original page") + + # Check that advert placements were also copied (there's a gotcha here, since the advert_placements + # relation is defined on Page, not EventPage) + self.assertEqual( + new_christmas_event.advert_placements.count(), 1, "Child objects defined on the superclass weren't copied" + ) + self.assertEqual( + christmas_event.advert_placements.count(), + 1, + "Child objects defined on the superclass were removed from the original page" + ) + + def test_create_alias_copies_parental_relations(self): + """Test that a page will be copied with parental many to many relations intact.""" + christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') + summer_category = EventCategory.objects.create(name='Summer') + holiday_category = EventCategory.objects.create(name='Holidays') + + # add parental many to many relations + christmas_event.categories = (summer_category, holiday_category) + christmas_event.save() + + # Copy it + new_christmas_event = christmas_event.create_alias(update_slug='new-christmas-event') + + # check that original eventt is untouched + self.assertEqual( + christmas_event.categories.count(), + 2, + "Child objects (parental many to many) defined on the superclass were removed from the original page" + ) + + # check that parental many to many are copied + self.assertEqual( + new_christmas_event.categories.count(), + 2, + "Child objects (parental many to many) weren't copied" + ) + + # check that the original and copy are related to the same categories + self.assertEqual( + new_christmas_event.categories.all().in_bulk(), + christmas_event.categories.all().in_bulk() + ) + + def test_create_alias_doesnt_copy_revisions(self): + christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') + christmas_event.save_revision() + + # Copy it + new_christmas_event = christmas_event.create_alias(update_slug='new-christmas-event') + + # Check that no revisions were created + self.assertEqual(new_christmas_event.revisions.count(), 0) + + def test_create_alias_copies_child_objects_with_nonspecific_class(self): + # Get chrismas page as Page instead of EventPage + christmas_event = Page.objects.get(url_path='/home/events/christmas/') + + # Copy it + new_christmas_event = christmas_event.create_alias(update_slug='new-christmas-event') + + # Check that the type of the new page is correct + self.assertIsInstance(new_christmas_event, EventPage) + + # Check that the speakers were copied + self.assertEqual(new_christmas_event.speakers.count(), 1, "Child objects weren't copied") + + def test_create_alias_copies_recursively(self): + events_index = EventIndex.objects.get(url_path='/home/events/') + + # Copy it + new_events_index = events_index.create_alias(recursive=True, update_slug='new-events-index') + + # Get christmas event + old_christmas_event = events_index.get_children().filter(slug='christmas').first() + new_christmas_event = new_events_index.get_children().filter(slug='christmas').first() + + # Check that the event exists in both places + self.assertNotEqual(new_christmas_event, None, "Child pages weren't copied") + self.assertNotEqual(old_christmas_event, None, "Child pages were removed from original page") + + # Check that the url path was updated + self.assertEqual(new_christmas_event.url_path, '/home/new-events-index/christmas/') + + # Check that the children were also created as aliases + self.assertEqual(new_christmas_event.alias_of, old_christmas_event) + + def test_create_alias_copies_recursively_with_child_objects(self): + events_index = EventIndex.objects.get(url_path='/home/events/') + + # Copy it + new_events_index = events_index.create_alias(recursive=True, update_slug='new-events-index') + + # Get christmas event + old_christmas_event = events_index.get_children().filter(slug='christmas').first() + new_christmas_event = new_events_index.get_children().filter(slug='christmas').first() + + # Check that the speakers were copied + self.assertEqual(new_christmas_event.specific.speakers.count(), 1, "Child objects weren't copied") + + # Check that the speakers weren't removed from old page + self.assertEqual( + old_christmas_event.specific.speakers.count(), 1, "Child objects were removed from the original page" + ) + + def test_create_alias_doesnt_copy_recursively_to_the_same_tree(self): + events_index = EventIndex.objects.get(url_path='/home/events/') + old_christmas_event = events_index.get_children().filter(slug='christmas').first().specific + old_christmas_event.save_revision() + + with self.assertRaises(Exception) as exception: + events_index.create_alias(recursive=True, parent=events_index) + + self.assertEqual(str(exception.exception), "You cannot copy a tree branch recursively into itself") + + def test_create_alias_updates_user(self): + event_moderator = get_user_model().objects.get(email='eventmoderator@example.com') + christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') + christmas_event.save_revision() + + # Copy it + new_christmas_event = christmas_event.create_alias(update_slug='new-christmas-event', user=event_moderator) + + # Check that the owner has been updated + self.assertEqual(new_christmas_event.owner, event_moderator) + + def test_create_alias_multi_table_inheritance(self): + saint_patrick_event = SingleEventPage.objects.get(url_path='/home/events/saint-patrick/') + + # Copy it + new_saint_patrick_event = saint_patrick_event.create_alias(update_slug='new-saint-patrick') + + # Check that new_saint_patrick_event is correct + self.assertIsInstance(new_saint_patrick_event, SingleEventPage) + self.assertEqual(new_saint_patrick_event.excerpt, saint_patrick_event.excerpt) + + # Check that new_saint_patrick_event is a different page, including parents from both EventPage and Page + self.assertNotEqual(saint_patrick_event.id, new_saint_patrick_event.id) + self.assertNotEqual(saint_patrick_event.eventpage_ptr.id, new_saint_patrick_event.eventpage_ptr.id) + self.assertNotEqual( + saint_patrick_event.eventpage_ptr.page_ptr.id, + new_saint_patrick_event.eventpage_ptr.page_ptr.id + ) + + # Check that the url path was updated + self.assertEqual(new_saint_patrick_event.url_path, '/home/events/new-saint-patrick/') + + # Check that both parent instance exists + self.assertIsInstance(EventPage.objects.get(id=new_saint_patrick_event.id), EventPage) + self.assertIsInstance(Page.objects.get(id=new_saint_patrick_event.id), Page) + + def test_create_alias_copies_tags(self): + # create and publish a TaggedPage under Events + event_index = Page.objects.get(url_path='/home/events/') + tagged_page = TaggedPage(title='My tagged page', slug='my-tagged-page') + tagged_page.tags.add('wagtail', 'bird') + event_index.add_child(instance=tagged_page) + tagged_page.save_revision().publish() + + old_tagged_item_ids = [item.id for item in tagged_page.tagged_items.all()] + # there should be two items here, with defined (truthy) IDs + self.assertEqual(len(old_tagged_item_ids), 2) + self.assertTrue(all(old_tagged_item_ids)) + + # copy to underneath homepage + homepage = Page.objects.get(url_path='/home/') + new_tagged_page = tagged_page.create_alias(parent=homepage) + + self.assertNotEqual(tagged_page.id, new_tagged_page.id) + + # new page should also have two tags + new_tagged_item_ids = [item.id for item in new_tagged_page.tagged_items.all()] + self.assertEqual(len(new_tagged_item_ids), 2) + self.assertTrue(all(new_tagged_item_ids)) + + # new tagged_item IDs should differ from old ones + self.assertTrue(all([ + item_id not in old_tagged_item_ids + for item_id in new_tagged_item_ids + ])) + + def test_create_alias_with_m2m_relations(self): + # create and publish a ManyToManyBlogPage under Events + event_index = Page.objects.get(url_path='/home/events/') + category = BlogCategory.objects.create(name='Birds') + advert = Advert.objects.create(url='http://www.heinz.com/', text="beanz meanz heinz") + + blog_page = ManyToManyBlogPage(title='My blog page', slug='my-blog-page') + event_index.add_child(instance=blog_page) + + blog_page.adverts.add(advert) + BlogCategoryBlogPage.objects.create(category=category, page=blog_page) + blog_page.save_revision().publish() + + # copy to underneath homepage + homepage = Page.objects.get(url_path='/home/') + new_blog_page = blog_page.create_alias(parent=homepage) + + # M2M relations are not formally supported, so for now we're only interested in + # the copy operation as a whole succeeding, rather than the child objects being copied + self.assertNotEqual(blog_page.id, new_blog_page.id) + + def test_create_alias_with_generic_foreign_key(self): + # create and publish a GenericSnippetPage under Events + event_index = Page.objects.get(url_path='/home/events/') + advert = Advert.objects.create(url='http://www.heinz.com/', text="beanz meanz heinz") + + page = GenericSnippetPage(title='My snippet page', slug='my-snippet-page') + page.snippet_content_object = advert + event_index.add_child(instance=page) + + page.save_revision().publish() + + # copy to underneath homepage + homepage = Page.objects.get(url_path='/home/') + new_page = page.create_alias(parent=homepage) + + self.assertNotEqual(page.id, new_page.id) + self.assertEqual(new_page.snippet_content_object, advert) + + def test_create_alias_with_o2o_relation(self): + event_index = Page.objects.get(url_path='/home/events/') + + page = OneToOnePage(title='My page', slug='my-page') + + event_index.add_child(instance=page) + + homepage = Page.objects.get(url_path='/home/') + new_page = page.create_alias(parent=homepage) + + self.assertNotEqual(page.id, new_page.id) + + @unittest.expectedFailure + def test_create_alias_with_additional_excluded_fields(self): + homepage = Page.objects.get(url_path='/home/') + page = homepage.add_child(instance=PageWithExcludedCopyField( + title='Discovery', + slug='disco', + content='NCC-1031', + special_field='Context is for Kings')) + new_page = page.create_alias(parent=homepage, update_slug='disco-2') + + self.assertEqual(page.title, new_page.title) + self.assertNotEqual(page.id, new_page.id) + self.assertNotEqual(page.path, new_page.path) + # special_field is in the list to be excluded + self.assertNotEqual(page.special_field, new_page.special_field) + + @unittest.expectedFailure + def test_create_alias_with_excluded_parental_and_child_relations(self): + """Test that a page will be copied with parental and child relations removed if excluded.""" + + try: + # modify excluded fields for this test + EventPage.exclude_fields_in_copy = ['advert_placements', 'categories', 'signup_link'] + + # set up data + christmas_event = EventPage.objects.get(url_path='/home/events/christmas/') + summer_category = EventCategory.objects.create(name='Summer') + holiday_category = EventCategory.objects.create(name='Holidays') + + # add URL (to test excluding a basic field) + christmas_event.signup_link = "https://christmas-is-awesome.com/rsvp" + + # add parental many to many relations + christmas_event.categories = (summer_category, holiday_category) + christmas_event.save() + + # Copy it + new_christmas_event = christmas_event.create_alias(update_slug='new-christmas-event') + + # check that the signup_link was NOT copied + self.assertEqual(christmas_event.signup_link, "https://christmas-is-awesome.com/rsvp") + self.assertEqual(new_christmas_event.signup_link, '') + + # check that original event is untouched + self.assertEqual( + christmas_event.categories.count(), + 2, + "Child objects (parental many to many) defined on the superclass were removed from the original page" + ) + + # check that parental many to many are NOT copied + self.assertEqual( + new_christmas_event.categories.count(), + 0, + "Child objects (parental many to many) were copied but should be excluded" + ) + + # check that child objects on original event were left untouched + self.assertEqual( + christmas_event.advert_placements.count(), + 1, + "Child objects defined on the original superclass were edited when copied" + ) + + # check that child objects were NOT copied + self.assertEqual( + new_christmas_event.advert_placements.count(), + 0, + "Child objects defined on the superclass were copied and should not be" + ) + + finally: + # reset excluded fields for future tests + EventPage.exclude_fields_in_copy = [] + + class TestCopyForTranslation(TestCase): fixtures = ['test.json']