diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 14d742a0f9..1c39a7603e 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -20,6 +20,7 @@ Changelog * Fix: Ensure `WAGTAILADMIN_LOGIN_URL` is respected when logging out of the admin (Antoine Rodriguez, Ramon de Jezus) * Fix: Fix behaviour of `ViewSet.inject_view_methods` with multiple methods (Gorlik) * Fix: Preserve query strings in URLs submitted to CloudFront for invalidation (Jigyasu Rajput) + * Fix: Handle non-JSON-safe fields in `exclude_fields_in_copy` (Matt Westcott) * Maintenance: Refactor `get_embed` to remove `finder` argument which was only used for mocking in unit tests (Jigyasu Rajput) * Maintenance: Simplify handling of `None` values in `TypedTableBlock` (Jigyasu Rajput) * Maintenance: Remove squash.io configuration (Sage Abdullah) diff --git a/docs/releases/7.1.md b/docs/releases/7.1.md index a014633639..1911b1daa5 100644 --- a/docs/releases/7.1.md +++ b/docs/releases/7.1.md @@ -32,6 +32,7 @@ depth: 1 * Ensure `WAGTAILADMIN_LOGIN_URL` is respected when logging out of the admin (Antoine Rodriguez, Ramon de Jezus) * Fix behaviour of `ViewSet.inject_view_methods` with multiple methods (Gorlik) * Preserve query strings in URLs submitted to CloudFront for invalidation (Jigyasu Rajput) + * Handle non-JSON-safe fields in `exclude_fields_in_copy` (Matt Westcott) ### Documentation diff --git a/wagtail/actions/copy_page.py b/wagtail/actions/copy_page.py index 2bd9e51164..70b8a5f0a0 100644 --- a/wagtail/actions/copy_page.py +++ b/wagtail/actions/copy_page.py @@ -181,6 +181,11 @@ class CopyPageAction: # Copy revisions if self.copy_revisions: + # Fetch data for the new page copy in the same serializable format that would be + # written to revisions. Any field in exclude_fields that is found in the revision data + # will be replaced with the corresponding field from here. + page_copy_data = page_copy.serializable_data() + for revision in page.revisions.all(): use_as_latest_revision = revision.pk == page.latest_revision_id revision.pk = None @@ -221,13 +226,9 @@ class CopyPageAction: ) ) - for exclude_field in specific_page.exclude_fields_in_copy: - if exclude_field in revision_content and hasattr( - page_copy, exclude_field - ): - revision_content[exclude_field] = getattr( - page_copy, exclude_field, None - ) + for field_name in exclude_fields: + if field_name in revision_content: + revision_content[field_name] = page_copy_data.get(field_name) revision.content = revision_content diff --git a/wagtail/test/testapp/migrations/0053_pagewithexcludedcopyfield_special_stream_and_more.py b/wagtail/test/testapp/migrations/0053_pagewithexcludedcopyfield_special_stream_and_more.py new file mode 100644 index 0000000000..46b758f8a5 --- /dev/null +++ b/wagtail/test/testapp/migrations/0053_pagewithexcludedcopyfield_special_stream_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.1 on 2025-05-16 10:44 + +import django.db.models.deletion +import modelcluster.fields +from django.db import migrations, models + +import wagtail.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("tests", "0052_variousondeletemodel_protected_page_and_user"), + ] + + operations = [ + migrations.AddField( + model_name="pagewithexcludedcopyfield", + name="special_stream", + field=wagtail.fields.StreamField( + [("item", 0)], + block_lookup={0: ("wagtail.blocks.CharBlock", (), {})}, + default=[("item", "default item")], + ), + ), + migrations.CreateModel( + name="ExcludedCopyPageNote", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ("note", models.CharField(max_length=255)), + ( + "page", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="special_notes", + to="tests.pagewithexcludedcopyfield", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py index 05e652621c..90bba77638 100644 --- a/wagtail/test/testapp/models.py +++ b/wagtail/test/testapp/models.py @@ -240,18 +240,34 @@ class CustomPreviewSizesPage(Page): return "desktop" +class ExcludedCopyPageNote(Orderable): + page = ParentalKey( + "tests.PageWithExcludedCopyField", + related_name="special_notes", + on_delete=models.CASCADE, + ) + note = models.CharField(max_length=255) + + panels = [FieldPanel("note")] + + # Page with Excluded Fields when copied class PageWithExcludedCopyField(Page): content = models.TextField() - # Exclude this field from being copied + # Exclude these fields and the special_notes relation from being copied special_field = models.CharField(blank=True, max_length=255, default="Very Special") - exclude_fields_in_copy = ["special_field"] + special_stream = StreamField( + [("item", CharBlock())], default=[("item", "default item")] + ) + exclude_fields_in_copy = ["special_field", "special_notes", "special_stream"] content_panels = [ TitleFieldPanel("title", classname="title"), FieldPanel("special_field"), FieldPanel("content"), + FieldPanel("special_stream"), + InlinePanel("special_notes", label="Special notes"), ] diff --git a/wagtail/tests/test_page_model.py b/wagtail/tests/test_page_model.py index 7fd86b742c..ec52f2bd16 100644 --- a/wagtail/tests/test_page_model.py +++ b/wagtail/tests/test_page_model.py @@ -1,4 +1,5 @@ import datetime +import json import unittest from unittest.mock import Mock @@ -49,6 +50,7 @@ from wagtail.test.testapp.models import ( EventIndex, EventPage, EventPageSpeaker, + ExcludedCopyPageNote, GenericSnippetPage, ManyToManyBlogPage, MTIBasePage, @@ -1994,24 +1996,34 @@ class TestCopyPage(TestCase): def test_copy_page_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", - ) + page = PageWithExcludedCopyField( + title="Discovery", + slug="disco", + content="NCC-1031", + special_field="Context is for Kings", + special_stream=[("item", "non-default item")], ) + page.special_notes = [ExcludedCopyPageNote(note="Some note")] + homepage.add_child(instance=page) page.save_revision() new_page = page.copy(to=homepage, update_attrs={"slug": "disco-2"}) - exclude_field = new_page.latest_revision.content["special_field"] + revision_content = new_page.latest_revision.content 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) - self.assertEqual(new_page.special_field, exclude_field) + + # special_field and special_stream are in the list to be excluded, + # and should revert to the default + self.assertEqual(new_page.special_field, "Very Special") + self.assertEqual(revision_content["special_field"], "Very Special") + self.assertEqual(new_page.special_stream[0].value, "default item") + stream_data = json.loads(revision_content["special_stream"]) + self.assertEqual(stream_data[0]["value"], "default item") + + # The special_notes relation should be cleared on the new page + self.assertEqual(new_page.special_notes.count(), 0) + self.assertEqual(revision_content["special_notes"], []) def test_page_with_generic_relation(self): """Test that a page with a GenericRelation will have that relation ignored when