From 4bed63dbb737d8ea2ab9811be4b0ac4a615ac9b9 Mon Sep 17 00:00:00 2001 From: Matt Westcott <matt@west.co.tt> Date: Wed, 24 Jul 2024 19:17:14 +0100 Subject: [PATCH] Prevent deconstruct_with_lookup from breaking on ListBlock subclasses with custom constructors Fixes #12164 --- wagtail/blocks/list_block.py | 24 +++++++--- wagtail/tests/test_streamfield.py | 77 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/wagtail/blocks/list_block.py b/wagtail/blocks/list_block.py index cb73f79ddb..152dda0f51 100644 --- a/wagtail/blocks/list_block.py +++ b/wagtail/blocks/list_block.py @@ -151,11 +151,20 @@ class ListBlock(Block): # Default to a list consisting of one empty (i.e. default-valued) child item self.meta.default = [self.child_block.get_default()] + # If a subclass of ListBlock overrides __init__, we cannot assume that the first argument is + # the child block, and thus we cannot rely on the conversion applied in construct_from_lookup / + # deconstruct_with_lookup to be valid. We set a flag attribute on the __init__ method so that + # we can spot this case. + __init__.has_child_block_arg = True + @classmethod - def construct_from_lookup(cls, lookup, child_block, **kwargs): - if isinstance(child_block, int): - child_block = lookup.get_block(child_block) - return cls(child_block, **kwargs) + def construct_from_lookup(cls, lookup, *args, **kwargs): + if getattr(cls.__init__, "has_child_block_arg", False) and isinstance( + args[0], int + ): + child_block = lookup.get_block(args[0]) + args = (child_block, *args[1:]) + return cls(*args, **kwargs) def value_from_datadict(self, data, files, prefix): count = int(data["%s-count" % prefix]) @@ -404,8 +413,11 @@ class ListBlock(Block): def deconstruct_with_lookup(self, lookup): path, args, kwargs = super().deconstruct_with_lookup(lookup) - if isinstance(args[0], Block): - args = (lookup.add_block(args[0]),) + if getattr(self.__init__, "has_child_block_arg", False) and isinstance( + args[0], Block + ): + block_id = lookup.add_block(args[0]) + args = (block_id, *args[1:]) return path, args, kwargs class Meta: diff --git a/wagtail/tests/test_streamfield.py b/wagtail/tests/test_streamfield.py index 6ad72b7e85..727847a678 100644 --- a/wagtail/tests/test_streamfield.py +++ b/wagtail/tests/test_streamfield.py @@ -824,6 +824,13 @@ class TestConstructStreamFieldFromLookup(TestCase): self.assertEqual(link_text_block.name, "link_text") +# Used by TestDeconstructStreamFieldWithLookup.test_deconstruct_with_listblock_subclass - +# needs to be a module-level definition so that the path returned from deconstruct is valid +class BulletListBlock(blocks.ListBlock): + def __init__(self, **kwargs): + super().__init__(blocks.CharBlock(required=True), **kwargs) + + class TestDeconstructStreamFieldWithLookup(TestCase): def test_deconstruct(self): class ButtonBlock(blocks.StructBlock): @@ -874,3 +881,73 @@ class TestDeconstructStreamFieldWithLookup(TestCase): }, }, ) + + def test_deconstruct_with_listblock(self): + field = StreamField( + [ + ("heading", blocks.CharBlock(required=True)), + ("bullets", blocks.ListBlock(blocks.CharBlock(required=True))), + ], + blank=True, + ) + field.set_attributes_from_name("body") + name, path, args, kwargs = field.deconstruct() + self.assertEqual(name, "body") + self.assertEqual(path, "wagtail.fields.StreamField") + self.assertEqual( + args, + [ + [ + ("heading", 0), + ("bullets", 1), + ] + ], + ) + self.assertEqual( + kwargs, + { + "blank": True, + "block_lookup": { + 0: ("wagtail.blocks.CharBlock", (), {"required": True}), + 1: ("wagtail.blocks.ListBlock", (0,), {}), + }, + }, + ) + + def test_deconstruct_with_listblock_subclass(self): + # See https://github.com/wagtail/wagtail/issues/12164 - unlike StructBlock and StreamBlock, + # ListBlock's deconstruct method doesn't reduce subclasses to the base ListBlock class. + # Therefore, if a ListBlock subclass defines its own __init__ method with an incompatible + # signature to the base ListBlock, this custom signature will be preserved in the result of + # deconstruct(), and we cannot rely on the first argument being the child block. + + field = StreamField( + [ + ("heading", blocks.CharBlock(required=True)), + ("bullets", BulletListBlock()), + ], + blank=True, + ) + field.set_attributes_from_name("body") + name, path, args, kwargs = field.deconstruct() + self.assertEqual(name, "body") + self.assertEqual(path, "wagtail.fields.StreamField") + self.assertEqual( + args, + [ + [ + ("heading", 0), + ("bullets", 1), + ] + ], + ) + self.assertEqual( + kwargs, + { + "blank": True, + "block_lookup": { + 0: ("wagtail.blocks.CharBlock", (), {"required": True}), + 1: ("wagtail.tests.test_streamfield.BulletListBlock", (), {}), + }, + }, + )