diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c1a50d2b43..308307f71f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -6,6 +6,7 @@ Changelog * Fix: Allow bulk publishing of pages without revisions (Andy Chosak) * Fix: Ensure that all descendant pages are logged when deleting a page, not just immediate children (Jake Howard) + * Fix: Translation key `IntegrityError` when publishing pages with translatable `Orderable`s that were copied without being published (Kalob Taulien, Dan Braghis) 2.15.4 (11.02.2022) diff --git a/docs/releases/2.15.5.rst b/docs/releases/2.15.5.rst index 5d339f0b30..0fa7dc2f13 100644 --- a/docs/releases/2.15.5.rst +++ b/docs/releases/2.15.5.rst @@ -15,6 +15,7 @@ Bug fixes * Allow bulk publishing of pages without revisions (Andy Chosak) * Ensure that all descendant pages are logged when deleting a page, not just immediate children (Jake Howard) + * Generate new translation keys for translatable ``Orderable`` when page is copied without being published (Kalob Taulien, Dan Braghis) Upgrade considerations diff --git a/wagtail/admin/tests/pages/test_copy_page.py b/wagtail/admin/tests/pages/test_copy_page.py index 34cf2c885f..bc80e58bcc 100644 --- a/wagtail/admin/tests/pages/test_copy_page.py +++ b/wagtail/admin/tests/pages/test_copy_page.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.urls import reverse from wagtail.core.models import GroupPagePermission, Page -from wagtail.tests.testapp.models import SimplePage +from wagtail.tests.testapp.models import EventPage, EventPageSpeaker, SimplePage from wagtail.tests.utils import WagtailTestUtils @@ -587,3 +587,57 @@ class TestPageCopy(TestCase, WagtailTestUtils): # We only need to check that it didn't crash self.assertEqual(response.status_code, 302) + + def test_copy_page_with_unique_uuids_in_orderables(self): + """ + Test that a page with orderables can be copied and the translation + keys are updated. + """ + event_page = EventPage( + title="Moon Landing", + location="the moon", + audience="public", + cost="free on TV", + date_from="1969-07-20", + ) + self.root_page.add_child(instance=event_page) + + event_page.speakers.add( + EventPageSpeaker( + first_name="Neil", + last_name="Armstrong", + ) + ) + # ensure there's a revision (which should capture the new speaker orderables) + # before copying the page + event_page.save_revision().publish() + + post_data = { + "new_title": "New Moon landing", + "new_slug": "moon-landing-redux", + "new_parent_page": str(self.root_page.id), + "copy_subpages": False, + "publish_copies": False, + "alias": False, + } + self.client.post( + reverse("wagtailadmin_pages:copy", args=[event_page.id]), post_data + ) + new_page = EventPage.objects.last() + + # Hack: get the page instance from the edit form which assembles it from the + # latest revision. While we could do new_page.get_latest_revision().as_page_object() + # this follow the edit view closer and should it change the test is less + # prone to continue working because we're skipping some step + response = self.client.get( + reverse("wagtailadmin_pages:edit", args=[new_page.id]) + ) + new_page_on_edit_form = response.context["form"].instance + + # publish the page, similar to EditView.publish_action() + new_page_on_edit_form.save_revision().publish() + + self.assertNotEqual( + event_page.speakers.first().translation_key, + new_page.speakers.first().translation_key, + ) diff --git a/wagtail/core/models/__init__.py b/wagtail/core/models/__init__.py index e3cfe26f1a..eca44a45d7 100644 --- a/wagtail/core/models/__init__.py +++ b/wagtail/core/models/__init__.py @@ -26,6 +26,7 @@ from django.core.cache import cache from django.core.exceptions import PermissionDenied, ValidationError from django.core.handlers.base import BaseHandler from django.core.handlers.wsgi import WSGIRequest +from django.core.serializers.json import DjangoJSONEncoder from django.db import models, transaction from django.db.models import DEFERRED, Q, Value from django.db.models.expressions import OuterRef, Subquery @@ -1584,6 +1585,17 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): page_copy, child_object_map = _copy(specific_self, exclude_fields=exclude_fields, update_attrs=base_update_attrs) + uuid_mapping = {} + + def generate_translation_key(uuid_mapping, old_uuid): + """ + Generates a new UUID based on an old one, then re-uses it + """ + if old_uuid not in uuid_mapping: + uuid_mapping[old_uuid] = uuid.uuid4() + + return uuid_mapping[old_uuid] + # Save copied child objects and run process_child_object on them if we need to for (child_relation, old_pk), child_object in child_object_map.items(): if process_child_object: @@ -1591,7 +1603,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): # 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 and isinstance(child_object, TranslatableMixin): - child_object.translation_key = uuid.uuid4() + child_object.translation_key = generate_translation_key(uuid_mapping, child_object.translation_key) # Save the new page if _mpnode_attrs: @@ -1642,7 +1654,12 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): copied_child_object = child_object_map.get((child_relation, child_object['pk'])) child_object['pk'] = copied_child_object.pk if copied_child_object else None - revision.content_json = json.dumps(revision_content) + if reset_translation_key and "translation_key" in child_object: + child_object["translation_key"] = generate_translation_key( + uuid_mapping, child_object["translation_key"] + ) + + revision.content_json = json.dumps(revision_content, cls=DjangoJSONEncoder) # Save revision.save()