From 37d4e841d7635d569d3fa486cb28a66b5a4ba187 Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Wed, 21 Oct 2020 20:24:45 +0100 Subject: [PATCH] Provide a raw_data accessor on StreamValue to allow accessing and modifying the raw JSON representation This matches the old behaviour of stream_data for lazy StreamValues. Given that a lazy StreamValue is what you get as standard when retrieving a model instance from the database (or from a PageRevision), any existing user code that casually tinkered with stream_data unaware of the lazy versus non-lazy gotcha is almost certainly expecting it to behave this way - therefore in 99% of cases those users should be able to replace `stream_data` with `raw_data` and have their code work as before, with the added bonus that it won't fall over on page previews (which get their data from a form submission in Python format rather than JSON, and are thus NOT lazy). --- wagtail/core/blocks/stream_block.py | 54 ++++++++++++++++++++++++++--- wagtail/core/tests/test_blocks.py | 51 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/wagtail/core/blocks/stream_block.py b/wagtail/core/blocks/stream_block.py index 5aebd13fee..3d3f8cb2f2 100644 --- a/wagtail/core/blocks/stream_block.py +++ b/wagtail/core/blocks/stream_block.py @@ -7,6 +7,7 @@ from django import forms from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.forms.utils import ErrorList from django.template.loader import render_to_string +from django.utils.functional import cached_property from django.utils.html import format_html_join from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ @@ -413,6 +414,49 @@ class StreamValue(MutableSequence): """ return self.block.name + def get_prep_value(self): + return { + 'type': self.block_type, + 'value': self.block.get_prep_value(self.value), + 'id': self.id, + } + + class RawDataView(MutableSequence): + """ + Internal helper class to present the stream data in raw JSONish format. For backwards + compatibility with old code that manipulated StreamValue.stream_data, this is considered + mutable to some extent, with the proviso that once the BoundBlock representation has been + accessed, any changes to fields within raw data will not propagate back to the BoundBlock + and will not be saved back when calling get_prep_value. + """ + def __init__(self, stream_value): + self.stream_value = stream_value + + def __getitem__(self, i): + item = self.stream_value._raw_data[i] + if item is None: + # reconstruct raw data from the bound block + item = self.stream_value._bound_blocks[i].get_prep_value() + self.stream_value._raw_data[i] = item + + return item + + def __len__(self): + return len(self.stream_value._raw_data) + + def __setitem__(self, i, item): + self.stream_value._raw_data[i] = item + # clear the cached bound_block for this item + self.stream_value._bound_blocks[i] = None + + def __delitem__(self, i): + # same as deletion on the stream itself - delete both the raw and bound_block data + del self.stream_value[i] + + def insert(self, i, item): + self.stream_value._raw_data.insert(i, item) + self.stream_value._bound_blocks.insert(i, None) + def __init__(self, stream_block, stream_data, is_lazy=False, raw_text=None): """ Construct a StreamValue linked to the given StreamBlock, @@ -485,6 +529,10 @@ class StreamValue(MutableSequence): self._bound_blocks.insert(i, self._construct_stream_child(item)) self._raw_data.insert(i, None) + @cached_property + def raw_data(self): + return StreamValue.RawDataView(self) + def _prefetch_blocks(self, type_name): """ Populate _bound_blocks with all items in this stream of type `type_name` that exist in @@ -519,11 +567,7 @@ class StreamValue(MutableSequence): if not item.id: item.id = str(uuid.uuid4()) - prep_value.append({ - 'type': item.block_type, - 'value': item.block.get_prep_value(item.value), - 'id': item.id, - }) + prep_value.append(item.get_prep_value()) else: # item has not been converted to a BoundBlock, so its _raw_data entry is # still usable (but ensure it has an ID before returning it) diff --git a/wagtail/core/tests/test_blocks.py b/wagtail/core/tests/test_blocks.py index 4b3741bf41..0316dd473e 100644 --- a/wagtail/core/tests/test_blocks.py +++ b/wagtail/core/tests/test_blocks.py @@ -3436,6 +3436,57 @@ class TestStreamBlock(WagtailTestUtils, SimpleTestCase): {'type': 'paragraph', 'value': 'of warcraft', 'id': '0003'}, ]) + def test_streamvalue_raw_data(self): + class ArticleBlock(blocks.StreamBlock): + heading = blocks.CharBlock() + paragraph = blocks.CharBlock() + + block = ArticleBlock() + stream = block.to_python([ + {'type': 'heading', 'value': 'hello', 'id': '0001'}, + {'type': 'paragraph', 'value': 'world', 'id': '0002'}, + ]) + + self.assertEqual(stream.raw_data[0], {'type': 'heading', 'value': 'hello', 'id': '0001'}) + stream.raw_data[0]['value'] = 'bonjour' + self.assertEqual(stream.raw_data[0], {'type': 'heading', 'value': 'bonjour', 'id': '0001'}) + + # changes to raw_data will be written back via get_prep_value... + raw_data = block.get_prep_value(stream) + self.assertEqual(raw_data, [ + {'type': 'heading', 'value': 'bonjour', 'id': '0001'}, + {'type': 'paragraph', 'value': 'world', 'id': '0002'}, + ]) + + # ...but once the bound-block representation has been accessed, that takes precedence + self.assertEqual(stream[0].value, 'bonjour') + stream.raw_data[0]['value'] = 'guten tag' + self.assertEqual(stream.raw_data[0]['value'], 'guten tag') + self.assertEqual(stream[0].value, 'bonjour') + raw_data = block.get_prep_value(stream) + self.assertEqual(raw_data, [ + {'type': 'heading', 'value': 'bonjour', 'id': '0001'}, + {'type': 'paragraph', 'value': 'world', 'id': '0002'}, + ]) + + # Replacing a raw_data entry outright will propagate to the bound block, though + stream.raw_data[0] = {'type': 'heading', 'value': 'konnichiwa', 'id': '0003'} + raw_data = block.get_prep_value(stream) + self.assertEqual(raw_data, [ + {'type': 'heading', 'value': 'konnichiwa', 'id': '0003'}, + {'type': 'paragraph', 'value': 'world', 'id': '0002'}, + ]) + self.assertEqual(stream[0].value, 'konnichiwa') + + # deletions / insertions on raw_data will also propagate to the bound block representation + del stream.raw_data[1] + stream.raw_data.insert(0, {'type': 'paragraph', 'value': 'hello kitty says', 'id': '0004'}) + raw_data = block.get_prep_value(stream) + self.assertEqual(raw_data, [ + {'type': 'paragraph', 'value': 'hello kitty says', 'id': '0004'}, + {'type': 'heading', 'value': 'konnichiwa', 'id': '0003'}, + ]) + def test_render_with_classname_via_kwarg(self): """form_classname from kwargs to be used as an additional class when rendering stream block"""