Handle non-JSON-safe fields in exclude_fields_in_copy

Fixes #11715

When copying a page's revision history, fields listed in exclude_fields_on_copy are replaced with the new value from the copied page instance (which has already had the exclude_fields_on_copy logic applied by this point, meaning that we'll get the model's default value instead). However, this does not take into account whether the value from the instance is JSON-serializable. This causes it to break when the field is a one-to-many or many-to-many relation, which returns the manager object (#11715), or a field that works with non-JSON-safe values such as StreamField.

Also, the old code was using the page model's exclude_fields_on_copy rather than the exclude_fields variable which also includes `default_exclude_fields_in_copy` and the passed-in `exclude_fields` argument - this meant that core fields such as `url_path` were not being replaced as expected.
pull/13082/head
Matt Westcott 2025-05-01 20:16:42 +01:00 zatwierdzone przez Sage Abdullah
rodzic d1fbb2e262
commit a2c20062bf
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: EB1A33CC51CC0217
6 zmienionych plików z 108 dodań i 20 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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,
},
),
]

Wyświetl plik

@ -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"),
]

Wyświetl plik

@ -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