angrybeanie_wagtail/env/lib/python3.12/site-packages/wagtail/tests/test_streamfield.py

1203 lines
46 KiB
Python
Raw Normal View History

2025-07-25 21:32:16 +10:00
import json
import pickle
from django.apps import apps
from django.db import connection, models
from django.template import Context, Template, engines
from django.test import TestCase, skipUnlessDBFeature
from django.utils.safestring import SafeString
from wagtail import blocks
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.blocks import StreamBlockValidationError, StreamValue
from wagtail.fields import StreamField
from wagtail.images.models import Image
from wagtail.images.tests.utils import get_test_image_file
from wagtail.models import Page
from wagtail.rich_text import RichText
from wagtail.signal_handlers import disable_reference_index_auto_update
from wagtail.test.testapp.models import (
ComplexDefaultStreamPage,
JSONBlockCountsStreamModel,
JSONMinMaxCountStreamModel,
JSONStreamModel,
StreamPage,
)
from wagtail.test.utils.form_data import nested_form_data, rich_text, streamfield
class TestLazyStreamField(TestCase):
model = JSONStreamModel
def setUp(self):
self.image = Image.objects.create(
title="Test image", file=get_test_image_file()
)
self.with_image = self.model.objects.create(
body=json.dumps(
[
{"type": "image", "value": self.image.pk},
{"type": "text", "value": "foo"},
]
)
)
self.no_image = self.model.objects.create(
body=json.dumps([{"type": "text", "value": "foo"}])
)
self.three_items = self.model.objects.create(
body=json.dumps(
[
{"type": "text", "value": "foo"},
{"type": "image", "value": self.image.pk},
{"type": "text", "value": "bar"},
]
)
)
def test_lazy_load(self):
"""
Getting a single item should lazily load the StreamField, only
accessing the database once the StreamField is accessed
"""
with self.assertNumQueries(1):
# Get the instance. The StreamField should *not* load the image yet
instance = self.model.objects.get(pk=self.with_image.pk)
with self.assertNumQueries(0):
# Access the body. The StreamField should still not get the image.
body = instance.body
with self.assertNumQueries(1):
# Access the image item from the stream. The image is fetched now
body[0].value
with self.assertNumQueries(0):
# Everything has been fetched now, no further database queries.
self.assertEqual(body[0].value, self.image)
self.assertEqual(body[1].value, "foo")
def test_slice(self):
with self.assertNumQueries(1):
instance = self.model.objects.get(pk=self.three_items.pk)
with self.assertNumQueries(1):
# Access the image item from the stream. The image is fetched now
instance.body[1].value
with self.assertNumQueries(0):
# taking a slice of a StreamValue should re-use already-fetched values
values = [block.value for block in instance.body[1:3]]
self.assertEqual(values, [self.image, "bar"])
with self.assertNumQueries(0):
# test slicing with negative indexing
values = [block.value for block in instance.body[-2:]]
self.assertEqual(values, [self.image, "bar"])
with self.assertNumQueries(0):
# test slicing with skips
values = [block.value for block in instance.body[0:3:2]]
self.assertEqual(values, ["foo", "bar"])
def test_lazy_load_no_images(self):
"""
Getting a single item whose StreamField never accesses the database
should behave as expected.
"""
with self.assertNumQueries(1):
# Get the instance, nothing else
instance = self.model.objects.get(pk=self.no_image.pk)
with self.assertNumQueries(0):
# Access the body. The StreamField has no images, so nothing should
# happen
body = instance.body
self.assertEqual(body[0].value, "foo")
def test_lazy_load_queryset(self):
"""
Ensure that lazy loading StreamField works when gotten as part of a
queryset list
"""
with self.assertNumQueries(1):
instances = self.model.objects.filter(
pk__in=[self.with_image.pk, self.no_image.pk]
)
instances_lookup = {instance.pk: instance for instance in instances}
with self.assertNumQueries(1):
instances_lookup[self.with_image.pk].body[0]
with self.assertNumQueries(0):
instances_lookup[self.no_image.pk].body[0]
def test_lazy_load_queryset_bulk(self):
"""
Ensure that lazy loading StreamField works when gotten as part of a
queryset list
"""
file_obj = get_test_image_file()
image_1 = Image.objects.create(title="Test image 1", file=file_obj)
image_3 = Image.objects.create(title="Test image 3", file=file_obj)
with_image = self.model.objects.create(
body=json.dumps(
[
{"type": "image", "value": image_1.pk},
{"type": "image", "value": None},
{"type": "image", "value": image_3.pk},
{"type": "text", "value": "foo"},
]
)
)
with self.assertNumQueries(1):
instance = self.model.objects.get(pk=with_image.pk)
# Prefetch all image blocks
with self.assertNumQueries(1):
instance.body[0]
# 1. Further image block access should not execute any db lookups
# 2. The blank block '1' should be None.
# 3. The values should be in the original order.
with self.assertNumQueries(0):
assert instance.body[0].value.title == "Test image 1"
assert instance.body[1].value is None
assert instance.body[2].value.title == "Test image 3"
def test_lazy_load_get_prep_value(self):
"""
Saving a lazy StreamField that hasn't had its data accessed should not
cause extra database queries by loading and then re-saving block values.
Instead the initial JSON stream data should be written back for any
blocks that have not been accessed.
"""
with self.assertNumQueries(1):
instance = self.model.objects.get(pk=self.with_image.pk)
# Expect a single UPDATE to update the model, without any additional
# SELECT related to the image block that has not been accessed.
with disable_reference_index_auto_update():
with self.assertNumQueries(1):
instance.save()
class TestSystemCheck(TestCase):
def tearDown(self):
# unregister InvalidStreamModel from the overall model registry
# so that it doesn't break tests elsewhere
for package in ("wagtailcore", "wagtail.tests"):
try:
del apps.all_models[package]["invalidstreammodel"]
except KeyError:
pass
apps.clear_cache()
def test_system_check_validates_block(self):
class InvalidStreamModel(models.Model):
body = StreamField(
[
("heading", blocks.CharBlock()),
("rich text", blocks.RichTextBlock()),
],
)
errors = InvalidStreamModel.check()
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].id, "wagtailcore.E001")
self.assertEqual(errors[0].hint, "Block names cannot contain spaces")
self.assertEqual(errors[0].obj, InvalidStreamModel._meta.get_field("body"))
class TestStreamValueAccess(TestCase):
def setUp(self):
self.json_body = JSONStreamModel.objects.create(
body=json.dumps([{"type": "text", "value": "foo"}])
)
def test_can_assign_as_list(self):
self.json_body.body = [("rich_text", RichText("<h2>hello world</h2>"))]
self.json_body.save()
# the body should now be a stream consisting of a single rich_text block
fetched_body = JSONStreamModel.objects.get(id=self.json_body.id).body
self.assertIsInstance(fetched_body, StreamValue)
self.assertEqual(len(fetched_body), 1)
self.assertIsInstance(fetched_body[0].value, RichText)
self.assertEqual(fetched_body[0].value.source, "<h2>hello world</h2>")
def test_normalize_on_assign(self):
self.json_body.body = [("rich_text", "<h2>hello world</h2>")]
self.json_body.save()
# the body should now be a stream consisting of a single rich_text block
fetched_body = JSONStreamModel.objects.get(id=self.json_body.id).body
self.assertIsInstance(fetched_body, StreamValue)
self.assertEqual(len(fetched_body), 1)
self.assertIsInstance(fetched_body[0].value, RichText)
self.assertEqual(fetched_body[0].value.source, "<h2>hello world</h2>")
def test_can_append(self):
self.json_body.body.append(("text", "bar"))
self.json_body.save()
fetched_body = JSONStreamModel.objects.get(id=self.json_body.id).body
self.assertIsInstance(fetched_body, StreamValue)
self.assertEqual(len(fetched_body), 2)
self.assertEqual(fetched_body[0].block_type, "text")
self.assertEqual(fetched_body[0].value, "foo")
self.assertEqual(fetched_body[1].block_type, "text")
self.assertEqual(fetched_body[1].value, "bar")
def test_normalize_on_append(self):
self.json_body.body.append(("rich_text", "<h2>hello world</h2>"))
self.json_body.save()
fetched_body = JSONStreamModel.objects.get(id=self.json_body.id).body
self.assertIsInstance(fetched_body, StreamValue)
self.assertEqual(len(fetched_body), 2)
self.assertEqual(fetched_body[0].block_type, "text")
self.assertEqual(fetched_body[0].value, "foo")
self.assertEqual(fetched_body[1].block_type, "rich_text")
self.assertIsInstance(fetched_body[1].value, RichText)
self.assertEqual(fetched_body[1].value.source, "<h2>hello world</h2>")
def test_normalize_on_replace(self):
self.json_body.body[0] = ("rich_text", "<h2>hello world</h2>")
self.json_body.save()
fetched_body = JSONStreamModel.objects.get(id=self.json_body.id).body
self.assertIsInstance(fetched_body, StreamValue)
self.assertEqual(len(fetched_body), 1)
self.assertEqual(fetched_body[0].block_type, "rich_text")
self.assertIsInstance(fetched_body[0].value, RichText)
self.assertEqual(fetched_body[0].value.source, "<h2>hello world</h2>")
def test_can_append_on_queried_instance(self):
# The test is analog to test_can_append(), but instead of working with the
# in-memory version from JSONStreamModel.objects.create(), we query a fresh
# instance from the db.
# It tests adding data to child blocks that
# have not yet been lazy loaded. This would previously crash.
self.json_body = JSONStreamModel.objects.get(pk=self.json_body.pk)
self.json_body.body.append(("text", "bar"))
self.json_body.save()
fetched_body = JSONStreamModel.objects.get(id=self.json_body.id).body
self.assertIsInstance(fetched_body, StreamValue)
self.assertEqual(len(fetched_body), 2)
self.assertEqual(fetched_body[0].block_type, "text")
self.assertEqual(fetched_body[0].value, "foo")
self.assertEqual(fetched_body[1].block_type, "text")
self.assertEqual(fetched_body[1].value, "bar")
def test_complex_assignment(self):
page = StreamPage(title="Test page", body=[])
page.body = [
("rich_text", "<h2>hello world</h2>"),
(
"books",
[
("title", "Great Expectations"),
("author", "Charles Dickens"),
],
),
]
self.assertEqual(page.body[0].block_type, "rich_text")
self.assertIsInstance(page.body[0].value, RichText)
self.assertEqual(page.body[0].value.source, "<h2>hello world</h2>")
self.assertEqual(page.body[1].block_type, "books")
self.assertIsInstance(page.body[1].value, StreamValue)
self.assertEqual(len(page.body[1].value), 2)
self.assertEqual(page.body[1].value[0].block_type, "title")
self.assertEqual(page.body[1].value[0].value, "Great Expectations")
self.assertEqual(page.body[1].value[1].block_type, "author")
self.assertEqual(page.body[1].value[1].value, "Charles Dickens")
class TestComplexDefault(TestCase):
def setUp(self):
self.page = ComplexDefaultStreamPage(title="Test page")
def test_default_value(self):
self.assertEqual(self.page.body[0].block_type, "rich_text")
self.assertIsInstance(self.page.body[0].value, RichText)
self.assertEqual(
self.page.body[0].value.source, "<p>My <i>lovely</i> books</p>"
)
self.assertEqual(self.page.body[1].block_type, "books")
self.assertIsInstance(self.page.body[1].value, StreamValue)
self.assertEqual(len(self.page.body[1].value), 2)
self.assertEqual(self.page.body[1].value[0].block_type, "title")
self.assertEqual(self.page.body[1].value[0].value, "The Great Gatsby")
self.assertEqual(self.page.body[1].value[1].block_type, "author")
self.assertEqual(self.page.body[1].value[1].value, "F. Scott Fitzgerald")
def test_creation_form_with_initial_instance(self):
form_class = ComplexDefaultStreamPage.get_edit_handler().get_form_class()
form = form_class(instance=self.page)
form_html = form.as_p()
self.assertIn("The Great Gatsby", form_html)
def test_creation_form_without_initial_instance(self):
form_class = ComplexDefaultStreamPage.get_edit_handler().get_form_class()
form = form_class()
form_html = form.as_p()
self.assertIn("The Great Gatsby", form_html)
class TestStreamFieldRenderingBase(TestCase):
model = JSONStreamModel
def setUp(self):
self.image = Image.objects.create(
title="Test image", file=get_test_image_file()
)
self.instance = self.model.objects.create(
body=json.dumps(
[
{"type": "rich_text", "value": "<p>Rich text</p>"},
{"type": "rich_text", "value": "<p>Привет, Микола</p>"},
{"type": "image", "value": self.image.pk},
{"type": "text", "value": "Hello, World!"},
]
)
)
img_tag = self.image.get_rendition("original").img_tag()
self.expected = "".join(
[
'<div class="block-rich_text"><p>Rich text</p></div>',
'<div class="block-rich_text"><p>Привет, Микола</p></div>',
f'<div class="block-image">{img_tag}</div>',
'<div class="block-text">Hello, World!</div>',
]
)
class TestStreamFieldRendering(TestStreamFieldRenderingBase):
def test_to_string(self):
rendered = str(self.instance.body)
self.assertHTMLEqual(rendered, self.expected)
self.assertIsInstance(rendered, SafeString)
def test___html___access(self):
rendered = self.instance.body.__html__()
self.assertHTMLEqual(rendered, self.expected)
self.assertIsInstance(rendered, SafeString)
class TestStreamFieldDjangoRendering(TestStreamFieldRenderingBase):
def render(self, string, context):
return Template(string).render(Context(context))
def test_render(self):
rendered = self.render("{{ instance.body }}", {"instance": self.instance})
self.assertHTMLEqual(rendered, self.expected)
class TestStreamFieldJinjaRendering(TestStreamFieldRenderingBase):
def setUp(self):
super().setUp()
self.engine = engines["jinja2"]
def render(self, string, context):
return self.engine.from_string(string).render(context)
def test_render(self):
rendered = self.render("{{ instance.body }}", {"instance": self.instance})
self.assertHTMLEqual(rendered, self.expected)
class TestRequiredStreamField(TestCase):
def test_non_blank_field_is_required(self):
# passing a block list
field = StreamField(
[("paragraph", blocks.CharBlock())],
blank=False,
)
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
class MyStreamBlock(blocks.StreamBlock):
paragraph = blocks.CharBlock()
class Meta:
required = False
# passing a block instance
field = StreamField(MyStreamBlock(), blank=False)
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
field = StreamField(
MyStreamBlock(required=False),
blank=False,
)
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
# passing a block class
field = StreamField(MyStreamBlock, blank=False)
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
def test_blank_false_is_implied_by_default(self):
# passing a block list
field = StreamField([("paragraph", blocks.CharBlock())])
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
class MyStreamBlock(blocks.StreamBlock):
paragraph = blocks.CharBlock()
class Meta:
required = False
# passing a block instance
field = StreamField(MyStreamBlock())
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
field = StreamField(MyStreamBlock(required=False))
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
# passing a block class
field = StreamField(MyStreamBlock)
self.assertTrue(field.stream_block.required)
with self.assertRaises(StreamBlockValidationError):
field.stream_block.clean([])
def test_blank_field_is_not_required(self):
# passing a block list
field = StreamField(
[("paragraph", blocks.CharBlock())],
blank=True,
)
self.assertFalse(field.stream_block.required)
field.stream_block.clean([]) # no validation error on empty stream
class MyStreamBlock(blocks.StreamBlock):
paragraph = blocks.CharBlock()
class Meta:
required = True
# passing a block instance
field = StreamField(MyStreamBlock(), blank=True)
self.assertFalse(field.stream_block.required)
field.stream_block.clean([]) # no validation error on empty stream
field = StreamField(MyStreamBlock(required=True), blank=True)
self.assertFalse(field.stream_block.required)
field.stream_block.clean([]) # no validation error on empty stream
# passing a block class
field = StreamField(MyStreamBlock, blank=True)
self.assertFalse(field.stream_block.required)
field.stream_block.clean([]) # no validation error on empty stream
class TestStreamFieldCountValidation(TestCase):
def setUp(self):
self.image = Image.objects.create(
title="Test image", file=get_test_image_file()
)
self.rich_text_body = {"type": "rich_text", "value": "<p>Rich text</p>"}
self.image_body = {"type": "image", "value": self.image.pk}
self.text_body = {"type": "text", "value": "Hello, World!"}
def test_minmax_pass_to_block(self):
instance = JSONMinMaxCountStreamModel.objects.create(body=json.dumps([]))
internal_block = instance.body.stream_block
self.assertEqual(internal_block.meta.min_num, 2)
self.assertEqual(internal_block.meta.max_num, 5)
def test_counts_pass_to_block(self):
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps([]))
block_counts = instance.body.stream_block.meta.block_counts
self.assertEqual(block_counts.get("text"), {"min_num": 1})
self.assertEqual(block_counts.get("rich_text"), {"max_num": 1})
self.assertEqual(block_counts.get("image"), {"min_num": 1, "max_num": 1})
def test_minimum_count(self):
# Single block should fail validation
body = [self.rich_text_body]
instance = JSONMinMaxCountStreamModel.objects.create(body=json.dumps(body))
with self.assertRaises(StreamBlockValidationError) as catcher:
instance.body.stream_block.clean(instance.body)
self.assertEqual(
catcher.exception.as_json_data(),
{"messages": ["The minimum number of items is 2"]},
)
# 2 blocks okay
body = [self.rich_text_body, self.text_body]
instance = JSONMinMaxCountStreamModel.objects.create(body=json.dumps(body))
self.assertTrue(instance.body.stream_block.clean(instance.body))
def test_minimum_count_disregarded_when_deferring_validation(self):
class StreamForm(WagtailAdminModelForm):
class Meta:
model = JSONMinMaxCountStreamModel
fields = ["body"]
defer_required_on_fields = ["body"]
form_data = nested_form_data(
{
"body": streamfield(
[
("text", "Some text"),
]
)
}
)
form = StreamForm(form_data)
self.assertFalse(form.is_valid())
form = StreamForm(form_data)
form.defer_required_fields()
self.assertTrue(form.is_valid())
def test_maximum_count_respected_when_deferring_validation(self):
class StreamForm(WagtailAdminModelForm):
class Meta:
model = JSONMinMaxCountStreamModel
fields = ["body"]
defer_required_on_fields = ["body"]
form_data = nested_form_data(
{
"body": streamfield(
[
("text", "Some text"),
("text", "Some text"),
("text", "Some text"),
("text", "Some text"),
("text", "Some text"),
("text", "Some text"),
]
)
}
)
form = StreamForm(form_data)
self.assertFalse(form.is_valid())
form = StreamForm(form_data)
form.defer_required_fields()
self.assertFalse(form.is_valid())
def test_maximum_count(self):
# 5 blocks okay
body = [self.rich_text_body] * 5
instance = JSONMinMaxCountStreamModel.objects.create(body=json.dumps(body))
self.assertTrue(instance.body.stream_block.clean(instance.body))
# 6 blocks should fail validation
body = [self.rich_text_body, self.text_body] * 3
instance = JSONMinMaxCountStreamModel.objects.create(body=json.dumps(body))
with self.assertRaises(StreamBlockValidationError) as catcher:
instance.body.stream_block.clean(instance.body)
self.assertEqual(
catcher.exception.as_json_data(),
{"messages": ["The maximum number of items is 5"]},
)
def test_block_counts_minimums(self):
JSONBlockCountsStreamModel.objects.create(body=json.dumps([]))
# Zero blocks should fail validation (requires one text, one image)
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps([]))
with self.assertRaises(StreamBlockValidationError) as catcher:
instance.body.stream_block.clean(instance.body)
errors = catcher.exception.as_json_data()["messages"]
self.assertIn("This field is required.", errors)
self.assertIn("Text: The minimum number of items is 1", errors)
self.assertIn("Image: The minimum number of items is 1", errors)
self.assertEqual(len(errors), 3)
# One plain text should fail validation
body = [self.text_body]
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps(body))
with self.assertRaises(StreamBlockValidationError) as catcher:
instance.body.stream_block.clean(instance.body)
self.assertEqual(
catcher.exception.as_json_data(),
{"messages": ["Image: The minimum number of items is 1"]},
)
# One text, one image should be okay
body = [self.text_body, self.image_body]
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps(body))
self.assertTrue(instance.body.stream_block.clean(instance.body))
def test_block_counts_maximums(self):
JSONBlockCountsStreamModel.objects.create(body=json.dumps([]))
# Base is one text, one image
body = [self.text_body, self.image_body]
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps(body))
self.assertTrue(instance.body.stream_block.clean(instance.body))
# Two rich text should error
body = [
self.text_body,
self.image_body,
self.rich_text_body,
self.rich_text_body,
]
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps(body))
with self.assertRaises(StreamBlockValidationError):
instance.body.stream_block.clean(instance.body)
# Two images should error
body = [self.text_body, self.image_body, self.image_body]
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps(body))
with self.assertRaises(StreamBlockValidationError) as catcher:
instance.body.stream_block.clean(instance.body)
self.assertEqual(
catcher.exception.as_json_data(),
{"messages": ["Image: The maximum number of items is 1"]},
)
# One text, one rich, one image should be okay
body = [self.text_body, self.image_body, self.rich_text_body]
instance = JSONBlockCountsStreamModel.objects.create(body=json.dumps(body))
self.assertTrue(instance.body.stream_block.clean(instance.body))
def test_block_count_minimum_disregarded_when_deferring_validation(self):
class StreamForm(WagtailAdminModelForm):
class Meta:
model = JSONBlockCountsStreamModel
fields = ["body"]
defer_required_on_fields = ["body"]
form_data = nested_form_data(
{
"body": streamfield(
[
("rich_text", rich_text("Some text")),
]
)
}
)
form = StreamForm(form_data)
self.assertFalse(form.is_valid())
form = StreamForm(form_data)
form.defer_required_fields()
self.assertTrue(form.is_valid())
def test_block_count_maximum_respected_when_deferring_validation(self):
class StreamForm(WagtailAdminModelForm):
class Meta:
model = JSONBlockCountsStreamModel
fields = ["body"]
defer_required_on_fields = ["body"]
form_data = nested_form_data(
{
"body": streamfield(
[
("rich_text", rich_text("Some text")),
("rich_text", rich_text("Some text")),
("rich_text", rich_text("Some text")),
]
)
}
)
form = StreamForm(form_data)
self.assertFalse(form.is_valid())
form = StreamForm(form_data)
form.defer_required_fields()
self.assertFalse(form.is_valid())
def test_streamfield_count_argument_precedence(self):
class TestStreamBlock(blocks.StreamBlock):
heading = blocks.CharBlock()
paragraph = blocks.RichTextBlock()
class Meta:
min_num = 2
max_num = 5
block_counts = {"heading": {"max_num": 1}}
# args being picked up from the class definition
field = StreamField(TestStreamBlock)
self.assertEqual(field.stream_block.meta.min_num, 2)
self.assertEqual(field.stream_block.meta.max_num, 5)
self.assertEqual(field.stream_block.meta.block_counts["heading"]["max_num"], 1)
# args being overridden by StreamField
field = StreamField(
TestStreamBlock,
min_num=3,
max_num=6,
block_counts={"heading": {"max_num": 2}},
)
self.assertEqual(field.stream_block.meta.min_num, 3)
self.assertEqual(field.stream_block.meta.max_num, 6)
self.assertEqual(field.stream_block.meta.block_counts["heading"]["max_num"], 2)
# passing None from StreamField should cancel limits set at the block level
field = StreamField(
TestStreamBlock,
min_num=None,
max_num=None,
block_counts=None,
)
self.assertIsNone(field.stream_block.meta.min_num)
self.assertIsNone(field.stream_block.meta.max_num)
self.assertIsNone(field.stream_block.meta.block_counts)
class TestJSONStreamField(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.instance = JSONStreamModel.objects.create(
body=[{"type": "text", "value": "foo"}],
)
def test_internal_type(self):
json = StreamField([("paragraph", blocks.CharBlock())])
self.assertEqual(json.get_internal_type(), "JSONField")
def test_json_body_equals_to_text_body(self):
instance_text = JSONStreamModel.objects.create(
body=json.dumps([{"type": "text", "value": "foo"}]),
)
self.assertEqual(
instance_text.body.render_as_block(), self.instance.body.render_as_block()
)
def test_json_body_create_preserialised_value(self):
instance_preserialised = JSONStreamModel.objects.create(
body=json.dumps([{"type": "text", "value": "foo"}]),
)
self.assertEqual(
instance_preserialised.body.render_as_block(),
self.instance.body.render_as_block(),
)
@skipUnlessDBFeature("supports_json_field_contains")
def test_json_contains_lookup(self):
value = {"value": "foo"}
if connection.features.json_key_contains_list_matching_requires_list:
value = [value]
instance = JSONStreamModel.objects.filter(body__contains=value).first()
self.assertIsNotNone(instance)
self.assertEqual(instance.id, self.instance.id)
class TestStreamFieldPickleSupport(TestCase):
def setUp(self):
# Find root page
self.root_page = Page.objects.get(id=2)
def test_pickle_support(self):
stream_page = StreamPage(title="stream page", body=[("text", "hello")])
self.root_page.add_child(instance=stream_page)
# check that page can be serialized / deserialized
serialized = pickle.dumps(stream_page)
deserialized = pickle.loads(serialized)
# check that serialized page can be serialized / deserialized again
serialized2 = pickle.dumps(deserialized)
deserialized2 = pickle.loads(serialized2)
# check that page data is not corrupted
self.assertEqual(stream_page.body, deserialized.body)
self.assertEqual(stream_page.body, deserialized2.body)
class TestGetBlockByContentPath(TestCase):
def setUp(self):
self.page = StreamPage(
title="Test page",
body=[
{"id": "123", "type": "text", "value": "Hello world"},
{
"id": "234",
"type": "product",
"value": {"name": "Cuddly toy", "price": "$9.95"},
},
{
"id": "345",
"type": "books",
"value": [
{"id": "111", "type": "author", "value": "Charles Dickens"},
{"id": "222", "type": "title", "value": "Great Expectations"},
],
},
{
"id": "456",
"type": "title_list",
"value": [
{"id": "111", "type": "item", "value": "Barnaby Rudge"},
{"id": "222", "type": "item", "value": "A Tale of Two Cities"},
],
},
],
)
def test_get_block_by_content_path(self):
field = self.page._meta.get_field("body")
# top-level blocks
bound_block = field.get_block_by_content_path(self.page.body, ["123"])
self.assertEqual(bound_block.value, "Hello world")
self.assertEqual(bound_block.block.name, "text")
bound_block = field.get_block_by_content_path(self.page.body, ["234"])
self.assertEqual(bound_block.block.name, "product")
bound_block = field.get_block_by_content_path(self.page.body, ["999"])
self.assertIsNone(bound_block)
# StructBlock children
bound_block = field.get_block_by_content_path(self.page.body, ["234", "name"])
self.assertEqual(bound_block.value, "Cuddly toy")
bound_block = field.get_block_by_content_path(self.page.body, ["234", "colour"])
self.assertIsNone(bound_block)
# StreamBlock children
bound_block = field.get_block_by_content_path(self.page.body, ["345", "111"])
self.assertEqual(bound_block.value, "Charles Dickens")
bound_block = field.get_block_by_content_path(self.page.body, ["345", "999"])
self.assertIsNone(bound_block)
# ListBlock children
bound_block = field.get_block_by_content_path(self.page.body, ["456", "111"])
self.assertEqual(bound_block.value, "Barnaby Rudge")
bound_block = field.get_block_by_content_path(self.page.body, ["456", "999"])
self.assertIsNone(bound_block)
class TestConstructStreamFieldFromLookup(TestCase):
def test_construct_block_list_from_lookup(self):
field = StreamField(
[
("heading", 0),
("paragraph", 1),
("button", 3),
],
block_lookup={
0: ("wagtail.blocks.CharBlock", [], {"required": True}),
1: ("wagtail.blocks.RichTextBlock", [], {}),
2: ("wagtail.blocks.PageChooserBlock", [], {}),
3: (
"wagtail.blocks.StructBlock",
[
[
("page", 2),
("link_text", 0),
]
],
{},
),
},
)
stream_block = field.stream_block
self.assertIsInstance(stream_block, blocks.StreamBlock)
self.assertEqual(len(stream_block.child_blocks), 3)
heading_block = stream_block.child_blocks["heading"]
self.assertIsInstance(heading_block, blocks.CharBlock)
self.assertTrue(heading_block.required)
self.assertEqual(heading_block.name, "heading")
paragraph_block = stream_block.child_blocks["paragraph"]
self.assertIsInstance(paragraph_block, blocks.RichTextBlock)
self.assertEqual(paragraph_block.name, "paragraph")
button_block = stream_block.child_blocks["button"]
self.assertIsInstance(button_block, blocks.StructBlock)
self.assertEqual(button_block.name, "button")
self.assertEqual(len(button_block.child_blocks), 2)
page_block = button_block.child_blocks["page"]
self.assertIsInstance(page_block, blocks.PageChooserBlock)
link_text_block = button_block.child_blocks["link_text"]
self.assertIsInstance(link_text_block, blocks.CharBlock)
self.assertEqual(link_text_block.name, "link_text")
def test_construct_top_level_block_from_lookup(self):
field = StreamField(
4,
block_lookup={
0: ("wagtail.blocks.CharBlock", [], {"required": True}),
1: ("wagtail.blocks.RichTextBlock", [], {}),
2: ("wagtail.blocks.PageChooserBlock", [], {}),
3: (
"wagtail.blocks.StructBlock",
[
[
("page", 2),
("link_text", 0),
]
],
{},
),
4: (
"wagtail.blocks.StreamBlock",
[
[
("heading", 0),
("paragraph", 1),
("button", 3),
]
],
{},
),
},
)
stream_block = field.stream_block
self.assertIsInstance(stream_block, blocks.StreamBlock)
self.assertEqual(len(stream_block.child_blocks), 3)
heading_block = stream_block.child_blocks["heading"]
self.assertIsInstance(heading_block, blocks.CharBlock)
self.assertTrue(heading_block.required)
self.assertEqual(heading_block.name, "heading")
paragraph_block = stream_block.child_blocks["paragraph"]
self.assertIsInstance(paragraph_block, blocks.RichTextBlock)
self.assertEqual(paragraph_block.name, "paragraph")
button_block = stream_block.child_blocks["button"]
self.assertIsInstance(button_block, blocks.StructBlock)
self.assertEqual(button_block.name, "button")
self.assertEqual(len(button_block.child_blocks), 2)
page_block = button_block.child_blocks["page"]
self.assertIsInstance(page_block, blocks.PageChooserBlock)
link_text_block = button_block.child_blocks["link_text"]
self.assertIsInstance(link_text_block, blocks.CharBlock)
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):
page = blocks.PageChooserBlock()
link_text = blocks.CharBlock(required=True)
field = StreamField(
[
("heading", blocks.CharBlock(required=True)),
("paragraph", blocks.RichTextBlock()),
("button", ButtonBlock()),
],
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),
("paragraph", 1),
("button", 3),
]
],
)
self.assertEqual(
kwargs,
{
"blank": True,
"block_lookup": {
0: ("wagtail.blocks.CharBlock", (), {"required": True}),
1: ("wagtail.blocks.RichTextBlock", (), {}),
2: ("wagtail.blocks.PageChooserBlock", (), {}),
3: (
"wagtail.blocks.StructBlock",
[
[
("page", 2),
("link_text", 0),
]
],
{},
),
},
},
)
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_with_child_block_kwarg(self):
field = StreamField(
[
("heading", blocks.CharBlock(required=True)),
(
"bullets",
blocks.ListBlock(child_block=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", (), {"child_block": 0}),
},
},
)
def test_deconstruct_with_listblock_with_child_block_kwarg_idempotence(self):
# See https://github.com/wagtail/wagtail/issues/13137. When a ListBlock is defined with
# a child_block keyword argument, its deconstruct_with_lookup method inserts that child
# block into the lookup to obtain an ID, and returns that ID as the child_block kwarg
# in its result. However, an implementation bug meant that this was mutating the kwargs
# dict stored in the block's _constructor_args attribute. As a result, subsequent calls
# to deconstruct_with_lookup (which happen routinely during makemigrations) would
# encounter the ID in child_block, leave it alone (because it isn't a block object as
# expected), and return that ID in the result without adding it to the lookup, messing
# up the ID sequence in the process.
field = StreamField(
[
("heading", blocks.CharBlock(required=True)),
(
"bullets",
blocks.ListBlock(child_block=blocks.CharBlock(required=False)),
),
],
blank=True,
)
field.set_attributes_from_name("body")
expected_args = [
[
("heading", 0),
("bullets", 2),
]
]
expected_kwargs = {
"blank": True,
"block_lookup": {
0: ("wagtail.blocks.CharBlock", (), {"required": True}),
1: ("wagtail.blocks.CharBlock", (), {"required": False}),
2: ("wagtail.blocks.ListBlock", (), {"child_block": 1}),
},
}
name, path, args, kwargs = field.deconstruct()
self.assertEqual(name, "body")
self.assertEqual(path, "wagtail.fields.StreamField")
self.assertEqual(kwargs, expected_kwargs)
self.assertEqual(args, expected_args)
name, path, args, kwargs = field.deconstruct()
self.assertEqual(name, "body")
self.assertEqual(path, "wagtail.fields.StreamField")
self.assertEqual(kwargs, expected_kwargs)
self.assertEqual(args, expected_args)
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", (), {}),
},
},
)