import base64 import collections import copy import json import unittest import unittest.mock from decimal import Decimal # non-standard import name for gettext_lazy, to prevent strings from being picked up for translation from django import forms from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.forms.utils import ErrorList from django.template.loader import render_to_string from django.test import SimpleTestCase, TestCase from django.utils.safestring import SafeData, mark_safe from django.utils.translation import gettext_lazy as _ from wagtail import blocks from wagtail.blocks.base import get_error_json_data from wagtail.blocks.definition_lookup import BlockDefinitionLookup from wagtail.blocks.field_block import FieldBlockAdapter from wagtail.blocks.list_block import ListBlockAdapter, ListBlockValidationError from wagtail.blocks.static_block import StaticBlockAdapter from wagtail.blocks.stream_block import StreamBlockAdapter, StreamBlockValidationError from wagtail.blocks.struct_block import StructBlockAdapter, StructBlockValidationError from wagtail.models import Page from wagtail.rich_text import RichText from wagtail.test.testapp.blocks import LinkBlock as CustomLinkBlock from wagtail.test.testapp.blocks import SectionBlock from wagtail.test.testapp.models import EventPage, SimplePage from wagtail.test.utils import WagtailTestUtils class FooStreamBlock(blocks.StreamBlock): text = blocks.CharBlock() error = 'At least one block must say "foo"' def clean(self, value): value = super().clean(value) if not any(block.value == "foo" for block in value): raise blocks.StreamBlockValidationError( non_block_errors=ErrorList([self.error]) ) return value class ContextCharBlock(blocks.CharBlock): def get_context(self, value, parent_context=None): value = str(value).upper() return super(blocks.CharBlock, self).get_context(value, parent_context) class TestBlock(SimpleTestCase): def test_normalize(self): """The base normalize implementation should return its argument unchanged""" obj = object() self.assertIs(blocks.Block().normalize(obj), obj) def test_block_definition_registry(self): """Instantiating a Block should register it in the definition registry""" block = blocks.Block() self.assertIs(blocks.Block.definition_registry[block.definition_prefix], block) def test_block_is_previewable(self): class CustomContextBlock(blocks.Block): def get_preview_context(self, value, parent_context=None): return {"value": value, "foo": "bar"} class CustomTemplateBlock(blocks.Block): def get_preview_template(self, value=None, context=None): return "foo.html" class CustomValueBlock(blocks.Block): def get_preview_value(self): return "foo" variants = { "no_config": [ blocks.Block(), ], "specific_template": [ blocks.Block(preview_template="foo.html"), CustomTemplateBlock(), ], "custom_value": [ blocks.Block(preview_value="foo"), blocks.Block(default="bar"), CustomContextBlock(), CustomValueBlock(), ], "specific_template_and_custom_value": [ blocks.Block(preview_template="foo.html", preview_value="bar"), ], "unset_default_not_none": [ blocks.ListBlock(blocks.Block()), blocks.StreamBlock(), blocks.StructBlock(), ], } # Test without a global template override cases = [ # Unconfigured block should not be previewable ("no_config", False), # Providing a specific preview template should make the block # previewable even without a custom preview value, as the content # may be hardcoded in the template ("specific_template", True), # Providing a preview value without a custom template should not # make the block previewable, as it may be missing the static assets ("custom_value", False), # Providing both a preview template and value also makes the block # previewable, this is the same as providing a custom template only ("specific_template_and_custom_value", True), # These blocks define their own unset default value that is not # `None`, and that value should not make it previewable ("unset_default_not_none", False), ] for variant, is_previewable in cases: with self.subTest(variant=variant, custom_global_template=False): for block in variants[variant]: self.assertIs(block.is_previewable, is_previewable) # Test with a global template override with unittest.mock.patch( "wagtail.blocks.base.template_is_overridden", return_value=True, ): cases = [ # Global template override + no preview value = not previewable, # since it's unlikely the global template alone will provide a # useful preview ("no_config", False), # Unchanged – specific template always makes the block previewable ("specific_template", True), # Global template override + custom preview value = previewable. # We assume the global template will provide the static assets, # and the custom value (and the block's real template via # {% include_block %}) will provide the content. ("custom_value", True), # Unchanged – providing both also makes the block previewable ("specific_template_and_custom_value", True), # Unchanged – even after providing a global template override, # these blocks should not be previewable ("unset_default_not_none", False), ] for variant, is_previewable in cases: with self.subTest(variant=variant, custom_global_template=True): for block in variants[variant]: del block.is_previewable # Clear cached_property self.assertIs(block.is_previewable, is_previewable) class TestFieldBlock(WagtailTestUtils, SimpleTestCase): def test_charfield_render(self): block = blocks.CharBlock() html = block.render("Hello world!") self.assertEqual(html, "Hello world!") def test_block_definition_registry(self): block = blocks.CharBlock(label="Test block") registered_block = blocks.Block.definition_registry[block.definition_prefix] self.assertIsInstance(registered_block, blocks.CharBlock) self.assertEqual(registered_block.meta.label, "Test block") self.assertIs(registered_block, block) def test_charfield_render_with_template(self): block = blocks.CharBlock(template="tests/blocks/heading_block.html") html = block.render("Hello world!") self.assertEqual(html, "

Hello world!

") def test_charblock_adapter(self): block = blocks.CharBlock(help_text="Some helpful text") block.set_name("test_block") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_block") self.assertIsInstance(js_args[1], forms.TextInput) self.assertEqual( js_args[2], { "label": "Test block", "description": "Some helpful text", "helpText": "Some helpful text", "required": True, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--char_field w-field--text_input", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_charblock_adapter_form_classname(self): """ Meta data test for FormField; this checks if both the meta values form_classname and classname are accepted and are rendered in the form """ block = blocks.CharBlock(form_classname="special-char-formclassname") block.set_name("test_block") js_args = FieldBlockAdapter().js_args(block) self.assertIn(" special-char-formclassname", js_args[2]["classname"]) # Checks if it is backward compatible with classname block_with_classname = blocks.CharBlock(classname="special-char-classname") block_with_classname.set_name("test_block") js_args = FieldBlockAdapter().js_args(block_with_classname) self.assertIn(" special-char-classname", js_args[2]["classname"]) def test_charfield_render_with_template_with_extra_context(self): block = ContextCharBlock(template="tests/blocks/heading_block.html") html = block.render( "Bonjour le monde!", context={ "language": "fr", }, ) self.assertEqual(html, '

BONJOUR LE MONDE!

') def test_charfield_get_form_state(self): block = blocks.CharBlock() form_state = block.get_form_state("Hello world!") self.assertEqual(form_state, "Hello world!") def test_charfield_searchable_content(self): block = blocks.CharBlock() content = block.get_searchable_content("Hello world!") self.assertEqual(content, ["Hello world!"]) def test_search_index_searchable_content(self): block = blocks.CharBlock(search_index=False) content = block.get_searchable_content("Hello world!") self.assertEqual(content, []) def test_charfield_with_validator(self): def validate_is_foo(value): if value != "foo": raise ValidationError("Value must be 'foo'") block = blocks.CharBlock(validators=[validate_is_foo]) with self.assertRaises(ValidationError): block.clean("bar") def test_choicefield_render(self): class ChoiceBlock(blocks.FieldBlock): field = forms.ChoiceField( choices=( ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ) ) block = ChoiceBlock() html = block.render("choice-2") self.assertEqual(html, "choice-2") def test_adapt_custom_choicefield(self): class ChoiceBlock(blocks.FieldBlock): field = forms.ChoiceField( choices=( ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ) ) block = ChoiceBlock(description="A selection of two choices") block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_choiceblock") self.assertIsInstance(js_args[1], forms.Select) self.assertEqual( js_args[1].choices, [ ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ], ) self.assertEqual( js_args[2], { "label": "Test choiceblock", "description": "A selection of two choices", "required": True, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--choice_field w-field--select", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_searchable_content(self): """ FieldBlock should not return anything for `get_searchable_content` by default. Subclasses are free to override it and provide relevant content. """ class CustomBlock(blocks.FieldBlock): field = forms.CharField(required=True) block = CustomBlock() self.assertEqual(block.get_searchable_content("foo bar"), []) def test_form_handling_is_independent_of_serialisation(self): class Base64EncodingCharBlock(blocks.CharBlock): """A CharBlock with a deliberately perverse JSON (de)serialisation format so that it visibly blows up if we call to_python / get_prep_value where we shouldn't """ def to_python(self, jsonish_value): # decode as base64 on the way out of the JSON serialisation return base64.b64decode(jsonish_value) def get_prep_value(self, native_value): # encode as base64 on the way into the JSON serialisation return base64.b64encode(native_value) block = Base64EncodingCharBlock() form_state = block.get_form_state("hello world") self.assertEqual(form_state, "hello world") def test_prepare_value_called(self): """ Check that Field.prepare_value is called before sending the value to the widget for rendering. Actual real-world use case: A Youtube field that produces YoutubeVideo instances from IDs, but videos are entered using their full URLs. """ class PrefixWrapper: prefix = "http://example.com/" def __init__(self, value): self.value = value def with_prefix(self): return self.prefix + self.value @classmethod def from_prefixed(cls, value): if not value.startswith(cls.prefix): raise ValueError return cls(value[len(cls.prefix) :]) def __eq__(self, other): return self.value == other.value class PrefixField(forms.Field): def clean(self, value): value = super().clean(value) return PrefixWrapper.from_prefixed(value) def prepare_value(self, value): return value.with_prefix() class PrefixedBlock(blocks.FieldBlock): def __init__(self, required=True, help_text="", **kwargs): super().__init__(**kwargs) self.field = PrefixField(required=required, help_text=help_text) block = PrefixedBlock() # Check that the form value is serialized with a prefix correctly value = PrefixWrapper("foo") form_state = block.get_form_state(value) self.assertEqual(form_state, "http://example.com/foo") # Check that the value was coerced back to a PrefixValue data = {"url": "http://example.com/bar"} new_value = block.clean(block.value_from_datadict(data, {}, "url")) self.assertEqual(new_value, PrefixWrapper("bar")) class TestIntegerBlock(unittest.TestCase): def test_type(self): block = blocks.IntegerBlock() digit = block.value_from_form(1234) self.assertEqual(type(digit), int) def test_render(self): block = blocks.IntegerBlock() digit = block.value_from_form(1234) self.assertEqual(digit, 1234) def test_render_required_error(self): block = blocks.IntegerBlock() with self.assertRaises(ValidationError): block.clean("") def test_render_max_value_validation(self): block = blocks.IntegerBlock(max_value=20) with self.assertRaises(ValidationError): block.clean(25) def test_render_min_value_validation(self): block = blocks.IntegerBlock(min_value=20) with self.assertRaises(ValidationError): block.clean(10) def test_render_with_validator(self): def validate_is_even(value): if value % 2 > 0: raise ValidationError("Value must be even") block = blocks.IntegerBlock(validators=[validate_is_even]) with self.assertRaises(ValidationError): block.clean(3) class TestEmailBlock(unittest.TestCase): def test_render(self): block = blocks.EmailBlock() email = block.render("example@email.com") self.assertEqual(email, "example@email.com") def test_render_required_error(self): block = blocks.EmailBlock() with self.assertRaises(ValidationError): block.clean("") def test_format_validation(self): block = blocks.EmailBlock() with self.assertRaises(ValidationError): block.clean("example.email.com") def test_render_with_validator(self): def validate_is_example_domain(value): if not value.endswith("@example.com"): raise ValidationError("E-mail address must end in @example.com") block = blocks.EmailBlock(validators=[validate_is_example_domain]) with self.assertRaises(ValidationError): block.clean("foo@example.net") class TestBooleanBlock(unittest.TestCase): def test_get_form_state(self): block = blocks.BooleanBlock(required=False) form_state = block.get_form_state(True) self.assertIs(form_state, True) form_state = block.get_form_state(False) self.assertIs(form_state, False) class TestBlockQuoteBlock(unittest.TestCase): def test_render(self): block = blocks.BlockQuoteBlock() quote = block.render("Now is the time...") self.assertEqual(quote, "
Now is the time...
") def test_render_with_validator(self): def validate_is_proper_story(value): if not value.startswith("Once upon a time"): raise ValidationError("Value must be a proper story") block = blocks.BlockQuoteBlock(validators=[validate_is_proper_story]) with self.assertRaises(ValidationError): block.clean("A long, long time ago") class TestFloatBlock(TestCase): def test_type(self): block = blocks.FloatBlock() block_val = block.value_from_form(float(1.63)) self.assertEqual(type(block_val), float) def test_render(self): block = blocks.FloatBlock() test_val = float(1.63) block_val = block.value_from_form(test_val) self.assertEqual(block_val, test_val) def test_raises_required_error(self): block = blocks.FloatBlock() with self.assertRaises(ValidationError): block.clean("") def test_raises_max_value_validation_error(self): block = blocks.FloatBlock(max_value=20) with self.assertRaises(ValidationError): block.clean("20.01") def test_raises_min_value_validation_error(self): block = blocks.FloatBlock(min_value=20) with self.assertRaises(ValidationError): block.clean("19.99") def test_render_with_validator(self): def validate_is_even(value): if value % 2 > 0: raise ValidationError("Value must be even") block = blocks.FloatBlock(validators=[validate_is_even]) with self.assertRaises(ValidationError): block.clean("3.0") class TestDecimalBlock(TestCase): def test_type(self): block = blocks.DecimalBlock() block_val = block.value_from_form(Decimal("1.63")) self.assertEqual(type(block_val), Decimal) def test_type_to_python(self): block = blocks.DecimalBlock() block_val = block.to_python( "1.63" ) # decimals get saved as string in JSON field self.assertEqual(type(block_val), Decimal) def test_type_to_python_decimal_none_value(self): block = blocks.DecimalBlock() block_val = block.to_python(None) self.assertIsNone(block_val) def test_render(self): block = blocks.DecimalBlock() test_val = Decimal(1.63) block_val = block.value_from_form(test_val) self.assertEqual(block_val, test_val) def test_raises_required_error(self): block = blocks.DecimalBlock() with self.assertRaises(ValidationError): block.clean("") def test_raises_max_value_validation_error(self): block = blocks.DecimalBlock(max_value=20) with self.assertRaises(ValidationError): block.clean("20.01") def test_raises_min_value_validation_error(self): block = blocks.DecimalBlock(min_value=20) with self.assertRaises(ValidationError): block.clean("19.99") def test_render_with_validator(self): def validate_is_even(value): if value % 2 > 0: raise ValidationError("Value must be even") block = blocks.DecimalBlock(validators=[validate_is_even]) with self.assertRaises(ValidationError): block.clean("3.0") def test_round_trip_to_db_preserves_type(self): block = blocks.DecimalBlock() original_value = Decimal(1.63) db_value = json.dumps( block.get_prep_value(original_value), cls=DjangoJSONEncoder ) restored_value = block.to_python(json.loads(db_value)) self.assertEqual(type(restored_value), Decimal) self.assertEqual(original_value, restored_value) class TestRegexBlock(TestCase): def test_render(self): block = blocks.RegexBlock(regex=r"^[0-9]{3}$") test_val = "123" block_val = block.value_from_form(test_val) self.assertEqual(block_val, test_val) def test_raises_required_error(self): block = blocks.RegexBlock(regex=r"^[0-9]{3}$") with self.assertRaises(ValidationError) as context: block.clean("") self.assertIn("This field is required.", context.exception.messages) def test_raises_custom_required_error(self): test_message = "Oops, you missed a bit." block = blocks.RegexBlock( regex=r"^[0-9]{3}$", error_messages={ "required": test_message, }, ) with self.assertRaises(ValidationError) as context: block.clean("") self.assertIn(test_message, context.exception.messages) def test_raises_validation_error(self): block = blocks.RegexBlock(regex=r"^[0-9]{3}$") with self.assertRaises(ValidationError) as context: block.clean("[/]") self.assertIn("Enter a valid value.", context.exception.messages) def test_raises_custom_error_message(self): test_message = "Not a valid library card number." block = blocks.RegexBlock( regex=r"^[0-9]{3}$", error_messages={"invalid": test_message} ) with self.assertRaises(ValidationError) as context: block.clean("[/]") self.assertIn(test_message, context.exception.messages) def test_render_with_validator(self): def validate_is_foo(value): if value != "foo": raise ValidationError("Value must be 'foo'") block = blocks.RegexBlock(regex=r"^.*$", validators=[validate_is_foo]) with self.assertRaises(ValidationError): block.clean("bar") class TestRichTextBlock(TestCase): fixtures = ["test.json"] def test_get_default_with_fallback_value(self): default_value = blocks.RichTextBlock().get_default() self.assertIsInstance(default_value, RichText) self.assertEqual(default_value.source, "") def test_get_default_with_default_none(self): default_value = blocks.RichTextBlock(default=None).get_default() self.assertIsInstance(default_value, RichText) self.assertEqual(default_value.source, "") def test_get_default_with_empty_string(self): default_value = blocks.RichTextBlock(default="").get_default() self.assertIsInstance(default_value, RichText) self.assertEqual(default_value.source, "") def test_get_default_with_nonempty_string(self): default_value = blocks.RichTextBlock(default="

foo

").get_default() self.assertIsInstance(default_value, RichText) self.assertEqual(default_value.source, "

foo

") def test_get_default_with_richtext_value(self): default_value = blocks.RichTextBlock( default=RichText("

foo

") ).get_default() self.assertIsInstance(default_value, RichText) self.assertEqual(default_value.source, "

foo

") def test_render(self): block = blocks.RichTextBlock() value = RichText('

Merry Christmas!

') result = block.render(value) self.assertEqual( result, '

Merry Christmas!

' ) def test_adapter(self): from wagtail.test.testapp.rich_text import CustomRichTextArea block = blocks.RichTextBlock(editor="custom") block.set_name("test_richtextblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_richtextblock") self.assertIsInstance(js_args[1], CustomRichTextArea) self.assertEqual( js_args[2], { "classname": "w-field w-field--char_field w-field--custom_rich_text_area", "icon": "pilcrow", "label": "Test richtextblock", "description": "", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "required": True, "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_adapter_with_draftail(self): from wagtail.admin.rich_text import DraftailRichTextArea block = blocks.RichTextBlock() block.set_name("test_richtextblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_richtextblock") self.assertIsInstance(js_args[1], DraftailRichTextArea) self.assertEqual( js_args[2], { "label": "Test richtextblock", "description": "", "required": True, "icon": "pilcrow", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--char_field w-field--draftail_rich_text_area", "showAddCommentButton": False, # Draftail manages its own comments "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_adapter_with_max_length(self): from wagtail.admin.rich_text import DraftailRichTextArea block = blocks.RichTextBlock(max_length=400) block.set_name("test_richtextblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_richtextblock") self.assertIsInstance(js_args[1], DraftailRichTextArea) self.assertEqual( js_args[2], { "label": "Test richtextblock", "description": "", "required": True, "icon": "pilcrow", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--char_field w-field--draftail_rich_text_area", "showAddCommentButton": False, # Draftail manages its own comments "strings": {"ADD_COMMENT": "Add Comment"}, "maxLength": 400, }, ) def test_validate_required_richtext_block(self): block = blocks.RichTextBlock() with self.assertRaises(ValidationError): block.clean(RichText("")) def test_validate_non_required_richtext_block(self): block = blocks.RichTextBlock(required=False) result = block.clean(RichText("")) self.assertIsInstance(result, RichText) self.assertEqual(result.source, "") def test_render_with_validator(self): def validate_contains_foo(value): if "foo" not in value: raise ValidationError("Value must contain 'foo'") block = blocks.RichTextBlock(validators=[validate_contains_foo]) with self.assertRaises(ValidationError): block.clean(RichText("

bar

")) def test_validate_max_length(self): block = blocks.RichTextBlock(max_length=20) block.clean(RichText("

short

")) with self.assertRaises(ValidationError): block.clean(RichText("

this exceeds the 20 character limit

")) block.clean( RichText( '

also short

' ) ) def test_get_searchable_content(self): block = blocks.RichTextBlock() value = RichText( '

Merry Christmas! & a happy new year

\n' "

Our Santa pet Wagtail has some cool stuff in store for you all!

" ) result = block.get_searchable_content(value) self.assertEqual( result, [ "Merry Christmas! & a happy new year \n" "Our Santa pet Wagtail has some cool stuff in store for you all!" ], ) def test_search_index_get_searchable_content(self): block = blocks.RichTextBlock(search_index=False) value = RichText( '

Merry Christmas! & a happy new year

\n' "

Our Santa pet Wagtail has some cool stuff in store for you all!

" ) result = block.get_searchable_content(value) self.assertEqual( result, [], ) def test_get_searchable_content_whitespace(self): block = blocks.RichTextBlock() value = RichText("

mashed

potatoes

") result = block.get_searchable_content(value) self.assertEqual(result, ["mashed potatoes"]) def test_extract_references(self): block = blocks.RichTextBlock() value = RichText('Link to an internal page') self.assertEqual(list(block.extract_references(value)), [(Page, "1", "", "")]) def test_normalize(self): block = blocks.RichTextBlock() for value in ("Hello, world", RichText("Hello, world")): with self.subTest(value=value): normalized = block.normalize(value) self.assertIsInstance(normalized, RichText) self.assertEqual(normalized.source, "Hello, world") class TestChoiceBlock(WagtailTestUtils, SimpleTestCase): def setUp(self): from django.db.models.fields import BLANK_CHOICE_DASH self.blank_choice_dash_label = BLANK_CHOICE_DASH[0][1] def test_adapt_choice_block(self): block = blocks.ChoiceBlock(choices=[("tea", "Tea"), ("coffee", "Coffee")]) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_choiceblock") self.assertIsInstance(js_args[1], forms.Select) self.assertEqual( list(js_args[1].choices), [("", "---------"), ("tea", "Tea"), ("coffee", "Coffee")], ) self.assertEqual( js_args[2], { "label": "Test choiceblock", "description": "", "required": True, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--choice_field w-field--select", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_choice_block_with_default(self): block = blocks.ChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], default="tea" ) self.assertEqual(block.get_default(), "tea") def test_adapt_choice_block_with_callable_choices(self): def callable_choices(): return [("tea", "Tea"), ("coffee", "Coffee")] block = blocks.ChoiceBlock(choices=callable_choices) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertIsInstance(js_args[1], forms.Select) self.assertEqual( list(js_args[1].choices), [("", "---------"), ("tea", "Tea"), ("coffee", "Coffee")], ) def test_validate_required_choice_block(self): block = blocks.ChoiceBlock(choices=[("tea", "Tea"), ("coffee", "Coffee")]) self.assertEqual(block.clean("coffee"), "coffee") with self.assertRaises(ValidationError): block.clean("whisky") with self.assertRaises(ValidationError): block.clean("") with self.assertRaises(ValidationError): block.clean(None) def test_adapt_non_required_choice_block(self): block = blocks.ChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], required=False ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertFalse(js_args[2]["required"]) def test_validate_non_required_choice_block(self): block = blocks.ChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], required=False ) self.assertEqual(block.clean("coffee"), "coffee") with self.assertRaises(ValidationError): block.clean("whisky") self.assertEqual(block.clean(""), "") self.assertEqual(block.clean(None), "") def test_adapt_choice_block_with_existing_blank_choice(self): block = blocks.ChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")], required=False, ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")], ) def test_adapt_choice_block_with_existing_blank_choice_and_with_callable_choices( self, ): def callable_choices(): return [("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")] block = blocks.ChoiceBlock(choices=callable_choices, required=False) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")], ) def test_named_groups_without_blank_option(self): block = blocks.ChoiceBlock( choices=[ ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ] ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ ("", "---------"), ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ], ) def test_named_groups_with_blank_option(self): block = blocks.ChoiceBlock( choices=[ ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ("Not thirsty", [("", "No thanks")]), ], required=False, ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ # Blank option not added ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ("Not thirsty", [("", "No thanks")]), ], ) def test_subclassing(self): class BeverageChoiceBlock(blocks.ChoiceBlock): choices = [ ("tea", "Tea"), ("coffee", "Coffee"), ] block = BeverageChoiceBlock(required=False) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ ("", "---------"), ("tea", "Tea"), ("coffee", "Coffee"), ], ) # subclasses of ChoiceBlock should deconstruct to a basic ChoiceBlock for migrations self.assertEqual( block.deconstruct(), ( "wagtail.blocks.ChoiceBlock", [], { "choices": [("tea", "Tea"), ("coffee", "Coffee")], "required": False, }, ), ) def test_searchable_content(self): block = blocks.ChoiceBlock( choices=[ ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ] ) self.assertEqual(block.get_searchable_content("choice-1"), ["Choice 1"]) def test_search_index_searchable_content(self): block = blocks.ChoiceBlock( choices=[ ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ], search_index=False, ) self.assertEqual(block.get_searchable_content("choice-1"), []) def test_searchable_content_with_callable_choices(self): def callable_choices(): return [ ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ] block = blocks.ChoiceBlock(choices=callable_choices) self.assertEqual(block.get_searchable_content("choice-1"), ["Choice 1"]) def test_optgroup_searchable_content(self): block = blocks.ChoiceBlock( choices=[ ( "Section 1", [ ("1-1", "Block 1"), ("1-2", "Block 2"), ], ), ( "Section 2", [ ("2-1", "Block 1"), ("2-2", "Block 2"), ], ), ] ) self.assertEqual(block.get_searchable_content("2-2"), ["Section 2", "Block 2"]) def test_invalid_searchable_content(self): block = blocks.ChoiceBlock( choices=[ ("one", "One"), ("two", "Two"), ] ) self.assertEqual(block.get_searchable_content("three"), []) def test_searchable_content_with_lazy_translation(self): block = blocks.ChoiceBlock( choices=[ ("choice-1", _("Choice 1")), ("choice-2", _("Choice 2")), ] ) result = block.get_searchable_content("choice-1") # result must survive JSON (de)serialisation, which is not the case for # lazy translation objects result = json.loads(json.dumps(result)) self.assertEqual(result, ["Choice 1"]) def test_optgroup_searchable_content_with_lazy_translation(self): block = blocks.ChoiceBlock( choices=[ ( _("Section 1"), [ ("1-1", _("Block 1")), ("1-2", _("Block 2")), ], ), ( _("Section 2"), [ ("2-1", _("Block 1")), ("2-2", _("Block 2")), ], ), ] ) result = block.get_searchable_content("2-2") # result must survive JSON (de)serialisation, which is not the case for # lazy translation objects result = json.loads(json.dumps(result)) self.assertEqual(result, ["Section 2", "Block 2"]) def test_deconstruct_with_callable_choices(self): def callable_choices(): return [ ("tea", "Tea"), ("coffee", "Coffee"), ] block = blocks.ChoiceBlock(choices=callable_choices, required=False) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ ("", "---------"), ("tea", "Tea"), ("coffee", "Coffee"), ], ) self.assertEqual( block.deconstruct(), ( "wagtail.blocks.ChoiceBlock", [], { "choices": callable_choices, "required": False, }, ), ) def test_render_with_validator(self): choices = [ ("tea", "Tea"), ("coffee", "Coffee"), ] def validate_tea_is_selected(value): raise ValidationError("You must select 'tea'") block = blocks.ChoiceBlock( choices=choices, validators=[validate_tea_is_selected] ) with self.assertRaises(ValidationError): block.clean("coffee") def test_get_form_state(self): block = blocks.ChoiceBlock(choices=[("tea", "Tea"), ("coffee", "Coffee")]) form_state = block.get_form_state("tea") self.assertEqual(form_state, ["tea"]) def test_get_form_state_with_radio_widget(self): block = blocks.ChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], widget=forms.RadioSelect ) form_state = block.get_form_state("tea") self.assertEqual(form_state, ["tea"]) class TestMultipleChoiceBlock(WagtailTestUtils, SimpleTestCase): def setUp(self): from django.db.models.fields import BLANK_CHOICE_DASH self.blank_choice_dash_label = BLANK_CHOICE_DASH[0][1] def test_adapt_multiple_choice_block(self): block = blocks.MultipleChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")] ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_choiceblock") self.assertIsInstance(js_args[1], forms.Select) self.assertEqual( list(js_args[1].choices), [("tea", "Tea"), ("coffee", "Coffee")] ) self.assertEqual( js_args[2], { "label": "Test choiceblock", "description": "", "required": True, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--multiple_choice_field w-field--select_multiple", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_multiple_choice_block_with_default(self): block = blocks.MultipleChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], default="tea" ) self.assertEqual(block.get_default(), "tea") def test_adapt_multiple_choice_block_with_callable_choices(self): def callable_choices(): return [("tea", "Tea"), ("coffee", "Coffee")] block = blocks.MultipleChoiceBlock(choices=callable_choices) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertIsInstance(js_args[1], forms.Select) self.assertEqual( list(js_args[1].choices), [("tea", "Tea"), ("coffee", "Coffee")] ) def test_validate_required_multiple_choice_block(self): block = blocks.MultipleChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")] ) self.assertEqual(block.clean(["coffee"]), ["coffee"]) with self.assertRaises(ValidationError): block.clean(["whisky"]) with self.assertRaises(ValidationError): block.clean("") with self.assertRaises(ValidationError): block.clean(None) def test_adapt_non_required_multiple_choice_block(self): block = blocks.MultipleChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], required=False ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertFalse(js_args[2]["required"]) def test_validate_non_required_multiple_choice_block(self): block = blocks.MultipleChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], required=False ) self.assertEqual(block.clean(["coffee"]), ["coffee"]) with self.assertRaises(ValidationError): block.clean(["whisky"]) self.assertEqual(block.clean(""), []) self.assertEqual(block.clean(None), []) def test_adapt_multiple_choice_block_with_existing_blank_choice(self): block = blocks.MultipleChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")], required=False, ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")], ) def test_adapt_multiple_choice_block_with_existing_blank_choice_and_with_callable_choices( self, ): def callable_choices(): return [("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")] block = blocks.MultipleChoiceBlock(choices=callable_choices, required=False) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [("tea", "Tea"), ("coffee", "Coffee"), ("", "No thanks")], ) def test_named_groups_without_blank_option(self): block = blocks.MultipleChoiceBlock( choices=[ ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ] ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ], ) def test_named_groups_with_blank_option(self): block = blocks.MultipleChoiceBlock( choices=[ ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ("Not thirsty", [("", "No thanks")]), ], required=False, ) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ ( "Alcoholic", [ ("gin", "Gin"), ("whisky", "Whisky"), ], ), ( "Non-alcoholic", [ ("tea", "Tea"), ("coffee", "Coffee"), ], ), ("Not thirsty", [("", "No thanks")]), ], ) def test_subclassing(self): class BeverageMultipleChoiceBlock(blocks.MultipleChoiceBlock): choices = [ ("tea", "Tea"), ("coffee", "Coffee"), ] block = BeverageMultipleChoiceBlock(required=False) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ ("tea", "Tea"), ("coffee", "Coffee"), ], ) # subclasses of ChoiceBlock should deconstruct to a basic ChoiceBlock for migrations self.assertEqual( block.deconstruct(), ( "wagtail.blocks.MultipleChoiceBlock", [], { "choices": [("tea", "Tea"), ("coffee", "Coffee")], "required": False, }, ), ) def test_searchable_content(self): block = blocks.MultipleChoiceBlock( choices=[ ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ] ) self.assertEqual(block.get_searchable_content("choice-1"), ["Choice 1"]) def test_search_index_searchable_content(self): block = blocks.MultipleChoiceBlock( choices=[ ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ], search_index=False, ) self.assertEqual(block.get_searchable_content("choice-1"), []) def test_searchable_content_with_callable_choices(self): def callable_choices(): return [ ("choice-1", "Choice 1"), ("choice-2", "Choice 2"), ] block = blocks.MultipleChoiceBlock(choices=callable_choices) self.assertEqual(block.get_searchable_content("choice-1"), ["Choice 1"]) def test_optgroup_searchable_content(self): block = blocks.MultipleChoiceBlock( choices=[ ( "Section 1", [ ("1-1", "Block 1"), ("1-2", "Block 2"), ], ), ( "Section 2", [ ("2-1", "Block 1"), ("2-2", "Block 2"), ], ), ] ) self.assertEqual(block.get_searchable_content("2-2"), ["Section 2", "Block 2"]) def test_invalid_searchable_content(self): block = blocks.MultipleChoiceBlock( choices=[ ("one", "One"), ("two", "Two"), ] ) self.assertEqual(block.get_searchable_content("three"), []) def test_searchable_content_with_lazy_translation(self): block = blocks.MultipleChoiceBlock( choices=[ ("choice-1", _("Choice 1")), ("choice-2", _("Choice 2")), ] ) result = block.get_searchable_content("choice-1") # result must survive JSON (de)serialisation, which is not the case for # lazy translation objects result = json.loads(json.dumps(result)) self.assertEqual(result, ["Choice 1"]) def test_optgroup_searchable_content_with_lazy_translation(self): block = blocks.MultipleChoiceBlock( choices=[ ( _("Section 1"), [ ("1-1", _("Block 1")), ("1-2", _("Block 2")), ], ), ( _("Section 2"), [ ("2-1", _("Block 1")), ("2-2", _("Block 2")), ], ), ] ) result = block.get_searchable_content("2-2") # result must survive JSON (de)serialisation, which is not the case for # lazy translation objects result = json.loads(json.dumps(result)) self.assertEqual(result, ["Section 2", "Block 2"]) def test_deconstruct_with_callable_choices(self): def callable_choices(): return [ ("tea", "Tea"), ("coffee", "Coffee"), ] block = blocks.MultipleChoiceBlock(choices=callable_choices, required=False) block.set_name("test_choiceblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual( list(js_args[1].choices), [ ("tea", "Tea"), ("coffee", "Coffee"), ], ) self.assertEqual( block.deconstruct(), ( "wagtail.blocks.MultipleChoiceBlock", [], { "choices": callable_choices, "required": False, }, ), ) def test_render_with_validator(self): choices = [ ("tea", "Tea"), ("coffee", "Coffee"), ] def validate_tea_is_selected(value): raise ValidationError("You must select 'tea'") block = blocks.MultipleChoiceBlock( choices=choices, validators=[validate_tea_is_selected] ) with self.assertRaises(ValidationError): block.clean("coffee") def test_get_form_state(self): block = blocks.MultipleChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")] ) form_state = block.get_form_state(["tea", "coffee"]) self.assertEqual(form_state, ["tea", "coffee"]) def test_get_form_state_with_checkbox_widget(self): block = blocks.ChoiceBlock( choices=[("tea", "Tea"), ("coffee", "Coffee")], widget=forms.CheckboxSelectMultiple, ) form_state = block.get_form_state(["tea", "coffee"]) self.assertEqual(form_state, ["tea", "coffee"]) class TestRawHTMLBlock(unittest.TestCase): def test_get_default_with_fallback_value(self): default_value = blocks.RawHTMLBlock().get_default() self.assertEqual(default_value, "") self.assertIsInstance(default_value, SafeData) def test_get_default_with_none(self): default_value = blocks.RawHTMLBlock(default=None).get_default() self.assertEqual(default_value, "") self.assertIsInstance(default_value, SafeData) def test_get_default_with_empty_string(self): default_value = blocks.RawHTMLBlock(default="").get_default() self.assertEqual(default_value, "") self.assertIsInstance(default_value, SafeData) def test_get_default_with_nonempty_string(self): default_value = blocks.RawHTMLBlock(default="BÖÖM").get_default() self.assertEqual(default_value, "BÖÖM") self.assertIsInstance(default_value, SafeData) def test_serialize(self): block = blocks.RawHTMLBlock() result = block.get_prep_value(mark_safe("BÖÖM")) self.assertEqual(result, "BÖÖM") self.assertNotIsInstance(result, SafeData) def test_deserialize(self): block = blocks.RawHTMLBlock() result = block.to_python("BÖÖM") self.assertEqual(result, "BÖÖM") self.assertIsInstance(result, SafeData) def test_render(self): block = blocks.RawHTMLBlock() result = block.render(mark_safe("BÖÖM")) self.assertEqual(result, "BÖÖM") self.assertIsInstance(result, SafeData) def test_get_form_state(self): block = blocks.RawHTMLBlock() form_state = block.get_form_state("BÖÖM") self.assertEqual(form_state, "BÖÖM") def test_adapt(self): block = blocks.RawHTMLBlock() block.set_name("test_rawhtmlblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_rawhtmlblock") self.assertIsInstance(js_args[1], forms.Textarea) self.assertEqual(js_args[1].attrs, {"cols": "40", "rows": "10"}) self.assertEqual( js_args[2], { "label": "Test rawhtmlblock", "description": "", "required": True, "icon": "code", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--char_field w-field--textarea", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_form_response(self): block = blocks.RawHTMLBlock() result = block.value_from_datadict( {"rawhtml": "BÖÖM"}, {}, prefix="rawhtml" ) self.assertEqual(result, "BÖÖM") self.assertIsInstance(result, SafeData) def test_value_omitted_from_data(self): block = blocks.RawHTMLBlock() self.assertFalse( block.value_omitted_from_data({"rawhtml": "ohai"}, {}, "rawhtml") ) self.assertFalse(block.value_omitted_from_data({"rawhtml": ""}, {}, "rawhtml")) self.assertTrue( block.value_omitted_from_data({"nothing-here": "nope"}, {}, "rawhtml") ) def test_clean_required_field(self): block = blocks.RawHTMLBlock() result = block.clean(mark_safe("BÖÖM")) self.assertEqual(result, "BÖÖM") self.assertIsInstance(result, SafeData) with self.assertRaises(ValidationError): block.clean(mark_safe("")) def test_clean_nonrequired_field(self): block = blocks.RawHTMLBlock(required=False) result = block.clean(mark_safe("BÖÖM")) self.assertEqual(result, "BÖÖM") self.assertIsInstance(result, SafeData) result = block.clean(mark_safe("")) self.assertEqual(result, "") self.assertIsInstance(result, SafeData) def test_render_with_validator(self): def validate_contains_foo(value): if "foo" not in value: raise ValidationError("Value must contain 'foo'") block = blocks.RawHTMLBlock(validators=[validate_contains_foo]) with self.assertRaises(ValidationError): block.clean(mark_safe("

bar

")) class TestMeta(unittest.TestCase): def test_set_template_with_meta(self): class HeadingBlock(blocks.CharBlock): class Meta: template = "heading.html" block = HeadingBlock() self.assertEqual(block.meta.template, "heading.html") def test_set_template_with_constructor(self): block = blocks.CharBlock(template="heading.html") self.assertEqual(block.meta.template, "heading.html") def test_set_template_with_constructor_overrides_meta(self): class HeadingBlock(blocks.CharBlock): class Meta: template = "heading.html" block = HeadingBlock(template="subheading.html") self.assertEqual(block.meta.template, "subheading.html") def test_meta_nested_inheritance(self): """ Check that having a multi-level inheritance chain works """ class HeadingBlock(blocks.CharBlock): class Meta: template = "heading.html" test = "Foo" class SubHeadingBlock(HeadingBlock): class Meta: template = "subheading.html" block = SubHeadingBlock() self.assertEqual(block.meta.template, "subheading.html") self.assertEqual(block.meta.test, "Foo") def test_meta_multi_inheritance(self): """ Check that multi-inheritance and Meta classes work together """ class LeftBlock(blocks.CharBlock): class Meta: template = "template.html" clash = "the band" label = "Left block" class RightBlock(blocks.CharBlock): class Meta: default = "hello" clash = "the album" label = "Right block" class ChildBlock(LeftBlock, RightBlock): class Meta: label = "Child block" block = ChildBlock() # These should be directly inherited from the LeftBlock/RightBlock self.assertEqual(block.meta.template, "template.html") self.assertEqual(block.meta.default, "hello") # This should be inherited from the LeftBlock, solving the collision, # as LeftBlock comes first self.assertEqual(block.meta.clash, "the band") # This should come from ChildBlock itself, ignoring the label on # LeftBlock/RightBlock self.assertEqual(block.meta.label, "Child block") class TestStructBlock(SimpleTestCase): def test_initialisation(self): block = blocks.StructBlock( [ ("title", blocks.CharBlock()), ("link", blocks.URLBlock()), ] ) self.assertEqual(list(block.child_blocks.keys()), ["title", "link"]) def test_initialisation_from_subclass(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = LinkBlock() self.assertEqual(list(block.child_blocks.keys()), ["title", "link"]) def test_initialisation_from_subclass_with_extra(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = LinkBlock([("classname", blocks.CharBlock())]) self.assertEqual( list(block.child_blocks.keys()), ["title", "link", "classname"] ) def test_initialisation_with_multiple_subclassses(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() class StyledLinkBlock(LinkBlock): classname = blocks.CharBlock() block = StyledLinkBlock() self.assertEqual( list(block.child_blocks.keys()), ["title", "link", "classname"] ) def test_initialisation_with_mixins(self): """ The order of fields of classes with multiple parent classes is slightly surprising at first. Child fields are inherited in a bottom-up order, by traversing the MRO in reverse. In the example below, ``StyledLinkBlock`` will have an MRO of:: [StyledLinkBlock, StylingMixin, LinkBlock, StructBlock, ...] This will result in ``classname`` appearing *after* ``title`` and ``link`` in ``StyleLinkBlock`.child_blocks`, even though ``StylingMixin`` appeared before ``LinkBlock``. """ class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() class StylingMixin(blocks.StructBlock): classname = blocks.CharBlock() class StyledLinkBlock(StylingMixin, LinkBlock): source = blocks.CharBlock() block = StyledLinkBlock() self.assertEqual( list(block.child_blocks.keys()), ["title", "link", "classname", "source"] ) def test_render(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = LinkBlock() html = block.render( block.to_python( { "title": "Wagtail site", "link": "http://www.wagtail.org", } ) ) expected_html = "\n".join( [ "
", "
title
", "
Wagtail site
", "
link
", "
http://www.wagtail.org
", "
", ] ) self.assertHTMLEqual(html, expected_html) def test_get_api_representation_calls_same_method_on_fields_with_context(self): """ The get_api_representation method of a StructBlock should invoke the block's get_api_representation method on each field and the context should be passed on. """ class ContextBlock(blocks.CharBlock): def get_api_representation(self, value, context=None): return context[value] class AuthorBlock(blocks.StructBlock): language = ContextBlock() author = ContextBlock() block = AuthorBlock() api_representation = block.get_api_representation( { "language": "en", "author": "wagtail", }, context={"en": "English", "wagtail": "Wagtail!"}, ) self.assertDictEqual( api_representation, {"language": "English", "author": "Wagtail!"} ) def test_render_unknown_field(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = LinkBlock() html = block.render( block.to_python( { "title": "Wagtail site", "link": "http://www.wagtail.org", "image": 10, } ) ) self.assertIn("
title
", html) self.assertIn("
Wagtail site
", html) self.assertIn("
link
", html) self.assertIn("
http://www.wagtail.org
", html) # Don't render the extra item self.assertNotIn("
image
", html) def test_render_bound_block(self): # the string representation of a bound block should be the value as rendered by # the associated block class SectionBlock(blocks.StructBlock): title = blocks.CharBlock() body = blocks.RichTextBlock() block = SectionBlock() struct_value = block.to_python( { "title": "hello", "body": "world", } ) body_bound_block = struct_value.bound_blocks["body"] expected = "world" self.assertEqual(str(body_bound_block), expected) def test_get_form_context(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = LinkBlock() context = block.get_form_context( block.to_python( { "title": "Wagtail site", "link": "http://www.wagtail.org", } ), prefix="mylink", ) self.assertIsInstance(context["children"], collections.OrderedDict) self.assertEqual(len(context["children"]), 2) self.assertIsInstance(context["children"]["title"], blocks.BoundBlock) self.assertEqual(context["children"]["title"].value, "Wagtail site") self.assertIsInstance(context["children"]["link"], blocks.BoundBlock) self.assertEqual(context["children"]["link"].value, "http://www.wagtail.org") self.assertEqual(context["block_definition"], block) self.assertEqual(context["prefix"], "mylink") def test_adapt(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock(required=False) link = blocks.URLBlock(required=False) block = LinkBlock() block.set_name("test_structblock") js_args = StructBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_structblock") self.assertEqual( js_args[2], { "label": "Test structblock", "description": "", "required": False, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "struct-block", }, ) self.assertEqual(len(js_args[1]), 2) title_field, link_field = js_args[1] self.assertEqual(title_field, block.child_blocks["title"]) self.assertEqual(link_field, block.child_blocks["link"]) def test_adapt_with_form_template(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock(required=False) link = blocks.URLBlock(required=False) class Meta: form_template = "tests/block_forms/struct_block_form_template.html" block = LinkBlock() block.set_name("test_structblock") js_args = StructBlockAdapter().js_args(block) self.assertEqual( js_args[2], { "label": "Test structblock", "description": "", "required": False, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "struct-block", "formTemplate": "
Hello
", }, ) def test_adapt_with_form_template_jinja(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock(required=False) link = blocks.URLBlock(required=False) class Meta: form_template = "tests/jinja2/struct_block_form_template.html" block = LinkBlock() block.set_name("test_structblock") js_args = StructBlockAdapter().js_args(block) self.assertEqual( js_args[2], { "label": "Test structblock", "description": "", "required": False, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "struct-block", "formTemplate": "
Hello
", }, ) def test_get_default(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock(default="Torchbox") link = blocks.URLBlock(default="http://www.torchbox.com") block = LinkBlock() default_val = block.get_default() self.assertEqual(default_val.get("title"), "Torchbox") def test_adapt_with_help_text_on_meta(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() class Meta: help_text = "Self-promotion is encouraged" block = LinkBlock() block.set_name("test_structblock") js_args = StructBlockAdapter().js_args(block) self.assertEqual( js_args[2], { "label": "Test structblock", "description": "Self-promotion is encouraged", "required": False, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "struct-block", "helpIcon": ( '' ), "helpText": "Self-promotion is encouraged", }, ) def test_adapt_with_help_text_as_argument(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = LinkBlock(help_text="Self-promotion is encouraged") block.set_name("test_structblock") js_args = StructBlockAdapter().js_args(block) self.assertEqual( js_args[2], { "label": "Test structblock", "description": "Self-promotion is encouraged", "required": False, "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "struct-block", "helpIcon": ( '' ), "helpText": "Self-promotion is encouraged", }, ) def test_searchable_content(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = LinkBlock() content = block.get_searchable_content( block.to_python( { "title": "Wagtail site", "link": "http://www.wagtail.org", } ) ) self.assertEqual(content, ["Wagtail site"]) def test_value_from_datadict(self): block = blocks.StructBlock( [ ("title", blocks.CharBlock()), ("link", blocks.URLBlock()), ] ) struct_val = block.value_from_datadict( {"mylink-title": "Torchbox", "mylink-link": "http://www.torchbox.com"}, {}, "mylink", ) self.assertEqual(struct_val["title"], "Torchbox") self.assertEqual(struct_val["link"], "http://www.torchbox.com") self.assertIsInstance(struct_val, blocks.StructValue) self.assertIsInstance(struct_val.bound_blocks["link"].block, blocks.URLBlock) def test_value_omitted_from_data(self): block = blocks.StructBlock( [ ("title", blocks.CharBlock()), ("link", blocks.URLBlock()), ] ) # overall value is considered present in the form if any sub-field is present self.assertFalse( block.value_omitted_from_data({"mylink-title": "Torchbox"}, {}, "mylink") ) self.assertTrue( block.value_omitted_from_data({"nothing-here": "nope"}, {}, "mylink") ) def test_default_is_returned_as_structvalue(self): """When returning the default value of a StructBlock (e.g. because it's a child of another StructBlock, and the outer value is missing that key) we should receive it as a StructValue, not just a plain dict""" class PersonBlock(blocks.StructBlock): first_name = blocks.CharBlock() surname = blocks.CharBlock() class EventBlock(blocks.StructBlock): title = blocks.CharBlock() guest_speaker = PersonBlock( default={"first_name": "Ed", "surname": "Balls"} ) event_block = EventBlock() event = event_block.to_python({"title": "Birthday party"}) self.assertEqual(event["guest_speaker"]["first_name"], "Ed") self.assertIsInstance(event["guest_speaker"], blocks.StructValue) def test_default_value_is_distinct_instance(self): """ Whenever the default value of a StructBlock is invoked, it should be a distinct instance of the dict so that modifying it doesn't modify other places where the default value appears. """ class PersonBlock(blocks.StructBlock): first_name = blocks.CharBlock() surname = blocks.CharBlock() class EventBlock(blocks.StructBlock): title = blocks.CharBlock() guest_speaker = PersonBlock( default={"first_name": "Ed", "surname": "Balls"} ) event_block = EventBlock() event1 = event_block.to_python( {"title": "Birthday party"} ) # guest_speaker will default to Ed Balls event2 = event_block.to_python( {"title": "Christmas party"} ) # guest_speaker will default to Ed Balls, but a distinct instance event1["guest_speaker"]["surname"] = "Miliband" self.assertEqual(event1["guest_speaker"]["surname"], "Miliband") # event2 should not be modified self.assertEqual(event2["guest_speaker"]["surname"], "Balls") def test_bulk_to_python_returns_distinct_default_instances(self): """ Whenever StructBlock.bulk_to_python invokes a child block's get_default method to fill in missing fields, it should use a separate invocation for each record so that we don't end up with the same instance of a mutable value on multiple records """ class ShoppingListBlock(blocks.StructBlock): shop = blocks.CharBlock() items = blocks.ListBlock(blocks.CharBlock(default="chocolate")) block = ShoppingListBlock() shopping_lists = block.bulk_to_python( [ {"shop": "Tesco"}, # 'items' defaults to ['chocolate'] { "shop": "Asda" }, # 'items' defaults to ['chocolate'], but a distinct instance ] ) shopping_lists[0]["items"].append("cake") self.assertEqual(list(shopping_lists[0]["items"]), ["chocolate", "cake"]) # shopping_lists[1] should not be updated self.assertEqual(list(shopping_lists[1]["items"]), ["chocolate"]) def test_clean(self): block = blocks.StructBlock( [ ("title", blocks.CharBlock()), ("link", blocks.URLBlock()), ] ) value = block.to_python( {"title": "Torchbox", "link": "http://www.torchbox.com/"} ) clean_value = block.clean(value) self.assertIsInstance(clean_value, blocks.StructValue) self.assertEqual(clean_value["title"], "Torchbox") value = block.to_python({"title": "Torchbox", "link": "not a url"}) with self.assertRaises(ValidationError): block.clean(value) def test_non_block_validation_error(self): class LinkBlock(blocks.StructBlock): page = blocks.PageChooserBlock(required=False) url = blocks.URLBlock(required=False) def clean(self, value): result = super().clean(value) if not (result["page"] or result["url"]): raise StructBlockValidationError( non_block_errors=ErrorList( ["Either page or URL must be specified"] ) ) return result block = LinkBlock() bad_data = {"page": None, "url": ""} with self.assertRaises(ValidationError): block.clean(bad_data) good_data = {"page": None, "url": "https://wagtail.org/"} self.assertEqual(block.clean(good_data), good_data) def test_bound_blocks_are_available_on_template(self): """ Test that we are able to use value.bound_blocks within templates to access a child block's own HTML rendering """ block = SectionBlock() value = block.to_python({"title": "Hello", "body": "italic world"}) result = block.render(value) self.assertEqual(result, """

Hello

italic world""") def test_render_block_with_extra_context(self): block = SectionBlock() value = block.to_python({"title": "Bonjour", "body": "monde italique"}) result = block.render(value, context={"language": "fr"}) self.assertEqual(result, """

Bonjour

monde italique""") def test_render_structvalue(self): """ The HTML representation of a StructValue should use the block's template """ block = SectionBlock() value = block.to_python({"title": "Hello", "body": "italic world"}) result = value.__html__() self.assertEqual(result, """

Hello

italic world""") # value.render_as_block() should be equivalent to value.__html__() result = value.render_as_block() self.assertEqual(result, """

Hello

italic world""") def test_str_structvalue(self): """ The str() representation of a StructValue should NOT render the template, as that's liable to cause an infinite loop if any debugging / logging code attempts to log the fact that it rendered a template with this object in the context: https://github.com/wagtail/wagtail/issues/2874 https://github.com/jazzband/django-debug-toolbar/issues/950 """ block = SectionBlock() value = block.to_python({"title": "Hello", "body": "italic world"}) result = str(value) self.assertNotIn("

", result) # The expected rendering should correspond to the native representation of an OrderedDict: # "StructValue([('title', u'Hello'), ('body', )])" # - give or take some quoting differences between Python versions self.assertIn("StructValue", result) self.assertIn("title", result) self.assertIn("Hello", result) def test_render_structvalue_with_extra_context(self): block = SectionBlock() value = block.to_python({"title": "Bonjour", "body": "monde italique"}) result = value.render_as_block(context={"language": "fr"}) self.assertEqual(result, """

Bonjour

monde italique""") def test_copy_structvalue(self): block = SectionBlock() value = block.to_python({"title": "Hello", "body": "world"}) copied = copy.copy(value) # Ensure we have a new object self.assertIsNot(value, copied) # Check copy operation self.assertIsInstance(copied, blocks.StructValue) self.assertIs(value.block, copied.block) self.assertEqual(value, copied) def test_normalize_base_cases(self): """Test the trivially recursive and already normalized cases""" block = blocks.StructBlock([("title", blocks.CharBlock())]) self.assertEqual( block.normalize({"title": "Foo"}), block._to_struct_value({"title": "Foo"}) ) self.assertEqual( block.normalize(block._to_struct_value({"title": "Foo"})), block._to_struct_value({"title": "Foo"}), ) def test_recursive_normalize(self): """StructBlock.normalize should recursively normalize all children""" block = blocks.StructBlock( [ ( "inner_stream", blocks.StreamBlock( [ ("inner_char", blocks.CharBlock()), ("inner_int", blocks.IntegerBlock()), ] ), ), ("list_of_ints", blocks.ListBlock(blocks.IntegerBlock())), ] ) # A value in the human friendly format value = { "inner_stream": [("inner_char", "Hello, world"), ("inner_int", 42)], "list_of_ints": [5, 6, 7, 8], } normalized = block.normalize(value) self.assertIsInstance(normalized, blocks.StructValue) self.assertIsInstance(normalized["inner_stream"], blocks.StreamValue) self.assertIsInstance( normalized["inner_stream"][0], blocks.StreamValue.StreamChild ) self.assertIsInstance( normalized["inner_stream"][1], blocks.StreamValue.StreamChild ) self.assertIsInstance(normalized["list_of_ints"], blocks.list_block.ListValue) self.assertIsInstance(normalized["list_of_ints"][0], int) class TestStructBlockWithCustomStructValue(SimpleTestCase): def test_initialisation(self): class CustomStructValue(blocks.StructValue): def joined(self): return self.get("title", "") + self.get("link", "") block = blocks.StructBlock( [ ("title", blocks.CharBlock()), ("link", blocks.URLBlock()), ], value_class=CustomStructValue, ) self.assertEqual(list(block.child_blocks.keys()), ["title", "link"]) block_value = block.to_python( {"title": "Birthday party", "link": "https://myparty.co.uk"} ) self.assertIsInstance(block_value, CustomStructValue) default_value = block.get_default() self.assertIsInstance(default_value, CustomStructValue) value_from_datadict = block.value_from_datadict( {"mylink-title": "Torchbox", "mylink-link": "http://www.torchbox.com"}, {}, "mylink", ) self.assertIsInstance(value_from_datadict, CustomStructValue) value = block.to_python( {"title": "Torchbox", "link": "http://www.torchbox.com/"} ) clean_value = block.clean(value) self.assertIsInstance(clean_value, CustomStructValue) self.assertEqual(clean_value["title"], "Torchbox") value = block.to_python({"title": "Torchbox", "link": "not a url"}) with self.assertRaises(ValidationError): block.clean(value) def test_initialisation_from_subclass(self): class LinkStructValue(blocks.StructValue): def url(self): return self.get("page") or self.get("link") class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() page = blocks.PageChooserBlock(required=False) link = blocks.URLBlock(required=False) class Meta: value_class = LinkStructValue block = LinkBlock() self.assertEqual(list(block.child_blocks.keys()), ["title", "page", "link"]) block_value = block.to_python( {"title": "Website", "link": "https://website.com"} ) self.assertIsInstance(block_value, LinkStructValue) default_value = block.get_default() self.assertIsInstance(default_value, LinkStructValue) def test_initialisation_with_multiple_subclassses(self): class LinkStructValue(blocks.StructValue): def url(self): return self.get("page") or self.get("link") class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() page = blocks.PageChooserBlock(required=False) link = blocks.URLBlock(required=False) class Meta: value_class = LinkStructValue class StyledLinkBlock(LinkBlock): classname = blocks.CharBlock() block = StyledLinkBlock() self.assertEqual( list(block.child_blocks.keys()), ["title", "page", "link", "classname"] ) value_from_datadict = block.value_from_datadict( { "queen-title": "Torchbox", "queen-link": "http://www.torchbox.com", "queen-classname": "fullsize", }, {}, "queen", ) self.assertIsInstance(value_from_datadict, LinkStructValue) def test_initialisation_with_mixins(self): class LinkStructValue(blocks.StructValue): pass class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() class Meta: value_class = LinkStructValue class StylingMixin(blocks.StructBlock): classname = blocks.CharBlock() class StyledLinkBlock(StylingMixin, LinkBlock): source = blocks.CharBlock() block = StyledLinkBlock() self.assertEqual( list(block.child_blocks.keys()), ["title", "link", "classname", "source"] ) block_value = block.to_python( { "title": "Website", "link": "https://website.com", "source": "google", "classname": "full-size", } ) self.assertIsInstance(block_value, LinkStructValue) def test_value_property(self): class SectionStructValue(blocks.StructValue): @property def foo(self): return "bar %s" % self.get("title", "") class SectionBlock(blocks.StructBlock): title = blocks.CharBlock() body = blocks.RichTextBlock() class Meta: value_class = SectionStructValue block = SectionBlock() struct_value = block.to_python({"title": "hello", "body": "world"}) value = struct_value.foo self.assertEqual(value, "bar hello") def test_render_with_template(self): class SectionStructValue(blocks.StructValue): def title_with_suffix(self): title = self.get("title") if title: return "SUFFIX %s" % title return "EMPTY TITLE" class SectionBlock(blocks.StructBlock): title = blocks.CharBlock(required=False) class Meta: value_class = SectionStructValue block = SectionBlock(template="tests/blocks/struct_block_custom_value.html") struct_value = block.to_python({"title": "hello"}) html = block.render(struct_value) self.assertEqual(html, "
SUFFIX hello
\n") struct_value = block.to_python({}) html = block.render(struct_value) self.assertEqual(html, "
EMPTY TITLE
\n") def test_normalize(self): """A normalized StructBlock value should be an instance of the StructBlock's value_class""" class CustomStructValue(blocks.StructValue): pass class CustomStructBlock(blocks.StructBlock): text = blocks.TextBlock() class Meta: value_class = CustomStructValue self.assertIsInstance( CustomStructBlock().normalize({"text": "She sells sea shells"}), CustomStructValue, ) def test_normalize_incorrect_value_class(self): """ If StructBlock.normalize is passed a StructValue instance that doesn't match the StructBlock's `value_class', it should convert the value to the correct class. """ class CustomStructValue(blocks.StructValue): pass class CustomStructBlock(blocks.StructBlock): text = blocks.TextBlock() class Meta: value_class = CustomStructValue block = CustomStructBlock() # Not an instance of CustomStructValue, which CustomStructBlock uses. value = blocks.StructValue(block, {"text": "The quick brown fox"}) self.assertIsInstance(block.normalize(value), CustomStructValue) class TestListBlock(WagtailTestUtils, SimpleTestCase): def assert_eq_list_values(self, p, q): # We can't directly compare ListValue instances yet self.assertEqual(list(p), list(q)) def test_initialise_with_class(self): block = blocks.ListBlock(blocks.CharBlock) # Child block should be initialised for us self.assertIsInstance(block.child_block, blocks.CharBlock) def test_initialise_with_instance(self): child_block = blocks.CharBlock() block = blocks.ListBlock(child_block) self.assertEqual(block.child_block, child_block) def render(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = blocks.ListBlock(LinkBlock()) return block.render( [ { "title": "Wagtail", "link": "http://www.wagtail.org", }, { "title": "Django", "link": "http://www.djangoproject.com", }, ] ) def test_render_uses_ul(self): html = self.render() self.assertIn("", html) def test_render_uses_li(self): html = self.render() self.assertIn("
  • ", html) self.assertIn("
  • ", html) def test_render_calls_block_render_on_children(self): """ The default rendering of a ListBlock should invoke the block's render method on each child, rather than just outputting the child value as a string. """ block = blocks.ListBlock( blocks.CharBlock(template="tests/blocks/heading_block.html") ) html = block.render(["Hello world!", "Goodbye world!"]) self.assertIn("

    Hello world!

    ", html) self.assertIn("

    Goodbye world!

    ", html) def test_render_passes_context_to_children(self): """ Template context passed to the render method should be passed on to the render method of the child block. """ block = blocks.ListBlock( blocks.CharBlock(template="tests/blocks/heading_block.html") ) html = block.render( ["Bonjour le monde!", "Au revoir le monde!"], context={ "language": "fr", }, ) self.assertIn('

    Bonjour le monde!

    ', html) self.assertIn('

    Au revoir le monde!

    ', html) def test_get_api_representation_calls_same_method_on_children_with_context(self): """ The get_api_representation method of a ListBlock should invoke the block's get_api_representation method on each child and the context should be passed on. """ class ContextBlock(blocks.CharBlock): def get_api_representation(self, value, context=None): return context[value] block = blocks.ListBlock(ContextBlock()) api_representation = block.get_api_representation( ["en", "fr"], context={"en": "Hello world!", "fr": "Bonjour le monde!"} ) self.assertEqual(api_representation, ["Hello world!", "Bonjour le monde!"]) def test_adapt(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = blocks.ListBlock(LinkBlock) block.set_name("test_listblock") js_args = ListBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_listblock") self.assertIsInstance(js_args[1], LinkBlock) self.assertEqual(js_args[2], {"title": None, "link": None}) self.assertEqual( js_args[3], { "label": "Test listblock", "description": "", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": None, "collapsed": False, "strings": { "DELETE": "Delete", "DUPLICATE": "Duplicate", "MOVE_DOWN": "Move down", "MOVE_UP": "Move up", "DRAG": "Drag", "ADD": "Add", }, }, ) def test_adapt_with_min_num_max_num(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = blocks.ListBlock(LinkBlock, min_num=2, max_num=5) block.set_name("test_listblock") js_args = ListBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_listblock") self.assertIsInstance(js_args[1], LinkBlock) self.assertEqual(js_args[2], {"title": None, "link": None}) self.assertEqual( js_args[3], { "label": "Test listblock", "description": "", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": None, "collapsed": False, "minNum": 2, "maxNum": 5, "strings": { "DELETE": "Delete", "DUPLICATE": "Duplicate", "MOVE_DOWN": "Move down", "MOVE_UP": "Move up", "DRAG": "Drag", "ADD": "Add", }, }, ) def test_searchable_content(self): class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = blocks.ListBlock(LinkBlock()) content = block.get_searchable_content( [ { "title": "Wagtail", "link": "http://www.wagtail.org", }, { "title": "Django", "link": "http://www.djangoproject.com", }, ] ) self.assertEqual(content, ["Wagtail", "Django"]) def test_value_omitted_from_data(self): block = blocks.ListBlock(blocks.CharBlock()) # overall value is considered present in the form if the 'count' field is present self.assertFalse( block.value_omitted_from_data({"mylist-count": "0"}, {}, "mylist") ) self.assertFalse( block.value_omitted_from_data( { "mylist-count": "1", "mylist-0-value": "hello", "mylist-0-deleted": "", "mylist-0-order": "0", }, {}, "mylist", ) ) self.assertTrue( block.value_omitted_from_data({"nothing-here": "nope"}, {}, "mylist") ) def test_id_from_form_submission_is_preserved(self): block = blocks.ListBlock(blocks.CharBlock()) post_data = {"shoppinglist-count": "3"} for i in range(0, 3): post_data.update( { "shoppinglist-%d-deleted" % i: "", "shoppinglist-%d-order" % i: str(i), "shoppinglist-%d-value" % i: "item %d" % i, "shoppinglist-%d-id" % i: "0000000%d" % i, } ) block_value = block.value_from_datadict(post_data, {}, "shoppinglist") self.assertEqual(block_value.bound_blocks[1].value, "item 1") self.assertEqual(block_value.bound_blocks[1].id, "00000001") def test_ordering_in_form_submission_uses_order_field(self): block = blocks.ListBlock(blocks.CharBlock()) # check that items are ordered by the 'order' field, not the order they appear in the form post_data = {"shoppinglist-count": "3"} for i in range(0, 3): post_data.update( { "shoppinglist-%d-deleted" % i: "", "shoppinglist-%d-order" % i: str(2 - i), "shoppinglist-%d-value" % i: "item %d" % i, "shoppinglist-%d-id" % i: "0000000%d" % i, } ) block_value = block.value_from_datadict(post_data, {}, "shoppinglist") self.assertEqual(block_value[2], "item 0") def test_ordering_in_form_submission_is_numeric(self): block = blocks.ListBlock(blocks.CharBlock()) # check that items are ordered by 'order' numerically, not alphabetically post_data = {"shoppinglist-count": "12"} for i in range(0, 12): post_data.update( { "shoppinglist-%d-deleted" % i: "", "shoppinglist-%d-order" % i: str(i), "shoppinglist-%d-value" % i: "item %d" % i, "shoppinglist-%d-id" % i: "0000000%d" % i, } ) block_value = block.value_from_datadict(post_data, {}, "shoppinglist") self.assertEqual(block_value[2], "item 2") def test_can_specify_default(self): block = blocks.ListBlock( blocks.CharBlock(), default=["peas", "beans", "carrots"] ) self.assertEqual(list(block.get_default()), ["peas", "beans", "carrots"]) def test_default_default(self): """ if no explicit 'default' is set on the ListBlock, it should fall back on a single instance of the child block in its default state. """ block = blocks.ListBlock(blocks.CharBlock(default="chocolate")) self.assertEqual(list(block.get_default()), ["chocolate"]) block.set_name("test_shoppinglistblock") js_args = ListBlockAdapter().js_args(block) self.assertEqual(js_args[2], "chocolate") def test_default_value_is_distinct_instance(self): """ Whenever the default value of a ListBlock is invoked, it should be a distinct instance of the list so that modifying it doesn't modify other places where the default value appears. """ class ShoppingListBlock(blocks.StructBlock): shop = blocks.CharBlock() items = blocks.ListBlock(blocks.CharBlock(default="chocolate")) block = ShoppingListBlock() tesco_shopping = block.to_python( {"shop": "Tesco"} ) # 'items' will default to ['chocolate'] asda_shopping = block.to_python( {"shop": "Asda"} ) # 'items' will default to ['chocolate'], but a distinct instance tesco_shopping["items"].append("cake") self.assertEqual(list(tesco_shopping["items"]), ["chocolate", "cake"]) # asda_shopping should not be modified self.assertEqual(list(asda_shopping["items"]), ["chocolate"]) def test_adapt_with_classname_via_kwarg(self): """form_classname from kwargs to be used as an additional class when rendering list block""" class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() block = blocks.ListBlock(LinkBlock, form_classname="special-list-class") block.set_name("test_listblock") js_args = ListBlockAdapter().js_args(block) self.assertEqual( js_args[3], { "label": "Test listblock", "description": "", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "special-list-class", "collapsed": False, "strings": { "DELETE": "Delete", "DUPLICATE": "Duplicate", "MOVE_DOWN": "Move down", "MOVE_UP": "Move up", "DRAG": "Drag", "ADD": "Add", }, }, ) def test_adapt_with_classname_via_class_meta(self): """form_classname from meta to be used as an additional class when rendering list block""" class LinkBlock(blocks.StructBlock): title = blocks.CharBlock() link = blocks.URLBlock() class CustomListBlock(blocks.ListBlock): class Meta: form_classname = "custom-list-class" block = CustomListBlock(LinkBlock) block.set_name("test_listblock") js_args = ListBlockAdapter().js_args(block) self.assertEqual( js_args[3], { "label": "Test listblock", "description": "", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "custom-list-class", "collapsed": False, "strings": { "DELETE": "Delete", "DUPLICATE": "Duplicate", "MOVE_DOWN": "Move down", "MOVE_UP": "Move up", "DRAG": "Drag", "ADD": "Add", }, }, ) def test_clean_preserves_block_ids(self): block = blocks.ListBlock(blocks.CharBlock()) block_val = block.to_python( [ { "type": "item", "value": "foo", "id": "11111111-1111-1111-1111-111111111111", }, { "type": "item", "value": "bar", "id": "22222222-2222-2222-2222-222222222222", }, ] ) cleaned_block_val = block.clean(block_val) self.assertEqual( cleaned_block_val.bound_blocks[0].id, "11111111-1111-1111-1111-111111111111" ) def test_min_num_validation_errors(self): block = blocks.ListBlock(blocks.CharBlock(), min_num=2) block_val = block.to_python(["foo"]) with self.assertRaises(ValidationError) as catcher: block.clean(block_val) self.assertEqual( catcher.exception.as_json_data(), { "messages": ["The minimum number of items is 2"], }, ) # a value with >= 2 blocks should pass validation block_val = block.to_python(["foo", "bar"]) self.assertTrue(block.clean(block_val)) def test_max_num_validation_errors(self): block = blocks.ListBlock(blocks.CharBlock(), max_num=2) block_val = block.to_python(["foo", "bar", "baz"]) with self.assertRaises(ValidationError) as catcher: block.clean(block_val) self.assertEqual( catcher.exception.as_json_data(), { "messages": ["The maximum number of items is 2"], }, ) # a value with <= 2 blocks should pass validation block_val = block.to_python(["foo", "bar"]) self.assertTrue(block.clean(block_val)) def test_unpack_old_database_format(self): block = blocks.ListBlock(blocks.CharBlock()) list_val = block.to_python(["foo", "bar"]) # list_val should behave as a list self.assertEqual(len(list_val), 2) self.assertEqual(list_val[0], "foo") # but also provide a bound_blocks property self.assertEqual(len(list_val.bound_blocks), 2) self.assertEqual(list_val.bound_blocks[0].value, "foo") # Bound blocks should be assigned UUIDs self.assertRegex(list_val.bound_blocks[0].id, r"[0-9a-f-]+") def test_bulk_unpack_old_database_format(self): block = blocks.ListBlock(blocks.CharBlock()) [list_1, list_2] = block.bulk_to_python([["foo", "bar"], ["xxx", "yyy", "zzz"]]) self.assertEqual(len(list_1), 2) self.assertEqual(len(list_2), 3) self.assertEqual(list_1[0], "foo") self.assertEqual(list_2[0], "xxx") # lists also provide a bound_blocks property self.assertEqual(len(list_1.bound_blocks), 2) self.assertEqual(list_1.bound_blocks[0].value, "foo") # Bound blocks should be assigned UUIDs self.assertRegex(list_1.bound_blocks[0].id, r"[0-9a-f-]+") def test_unpack_new_database_format(self): block = blocks.ListBlock(blocks.CharBlock()) list_val = block.to_python( [ { "type": "item", "value": "foo", "id": "11111111-1111-1111-1111-111111111111", }, { "type": "item", "value": "bar", "id": "22222222-2222-2222-2222-222222222222", }, ] ) # list_val should behave as a list self.assertEqual(len(list_val), 2) self.assertEqual(list_val[0], "foo") # but also provide a bound_blocks property self.assertEqual(len(list_val.bound_blocks), 2) self.assertEqual(list_val.bound_blocks[0].value, "foo") self.assertEqual( list_val.bound_blocks[0].id, "11111111-1111-1111-1111-111111111111" ) def test_bulk_unpack_new_database_format(self): block = blocks.ListBlock(blocks.CharBlock()) [list_1, list_2] = block.bulk_to_python( [ [ { "type": "item", "value": "foo", "id": "11111111-1111-1111-1111-111111111111", }, { "type": "item", "value": "bar", "id": "22222222-2222-2222-2222-222222222222", }, ], [ { "type": "item", "value": "baz", "id": "33333333-3333-3333-3333-333333333333", }, ], ] ) self.assertEqual(len(list_1), 2) self.assertEqual(len(list_2), 1) self.assertEqual(list_1[0], "foo") self.assertEqual(list_2[0], "baz") # lists also provide a bound_blocks property self.assertEqual(len(list_1.bound_blocks), 2) self.assertEqual(list_1.bound_blocks[0].value, "foo") self.assertEqual( list_1.bound_blocks[0].id, "11111111-1111-1111-1111-111111111111" ) def test_assign_listblock_with_list(self): stream_block = blocks.StreamBlock( [ ("bullet_list", blocks.ListBlock(blocks.CharBlock())), ] ) stream_value = stream_block.to_python([]) stream_value.append(("bullet_list", ["foo", "bar"])) clean_stream_value = stream_block.clean(stream_value) result = stream_block.get_prep_value(clean_stream_value) self.assertEqual(result[0]["type"], "bullet_list") self.assertEqual(len(result[0]["value"]), 2) self.assertEqual(result[0]["value"][0]["value"], "foo") def test_normalize_base_case(self): """Test normalize when trivially recursive, or already a ListValue""" block = blocks.ListBlock(blocks.IntegerBlock) normalized = block.normalize([0, 1, 1, 2, 3]) self.assertIsInstance(normalized, blocks.list_block.ListValue) self.assert_eq_list_values(normalized, [0, 1, 1, 2, 3]) normalized = block.normalize( blocks.list_block.ListValue(block, [0, 1, 1, 2, 3]) ) self.assertIsInstance(normalized, blocks.list_block.ListValue) self.assert_eq_list_values(normalized, [0, 1, 1, 2, 3]) def test_normalize_empty(self): block = blocks.ListBlock(blocks.IntegerBlock()) normalized = block.normalize([]) self.assertIsInstance(normalized, blocks.list_block.ListValue) self.assert_eq_list_values(normalized, []) def test_recursive_normalize(self): """ ListBlock.normalize should recursively normalize all values passed to it, and return a ListValue. """ inner_list_block = blocks.ListBlock(blocks.IntegerBlock()) block = blocks.ListBlock(inner_list_block) values = [ [[1, 2, 3]], [blocks.list_block.ListValue(block, [1, 2, 3])], ] for value in values: normalized = block.normalize(value) self.assertIsInstance(normalized, blocks.list_block.ListValue) self.assert_eq_list_values(normalized[0], [1, 2, 3]) class TestListBlockWithFixtures(TestCase): fixtures = ["test.json"] def test_calls_child_bulk_to_python_when_available(self): page_ids = [2, 3, 4, 5] expected_pages = Page.objects.filter(pk__in=page_ids) block = blocks.ListBlock(blocks.PageChooserBlock()) with self.assertNumQueries(1): pages = block.to_python(page_ids) self.assertSequenceEqual(pages, expected_pages) def test_bulk_to_python(self): block = blocks.ListBlock(blocks.PageChooserBlock()) with self.assertNumQueries(1): result = block.bulk_to_python([[4, 5], [], [2]]) # result will be a list of ListValues - convert to lists for equality check clean_result = [list(val) for val in result] self.assertEqual( clean_result, [ [Page.objects.get(id=4), Page.objects.get(id=5)], [], [Page.objects.get(id=2)], ], ) def test_extract_references(self): block = blocks.ListBlock(blocks.PageChooserBlock()) christmas_page = Page.objects.get(slug="christmas") saint_patrick_page = Page.objects.get(slug="saint-patrick") self.assertListEqual( list( block.extract_references( block.to_python( [ { "id": "block1", "type": "item", "value": christmas_page.id, }, { "id": "block2", "type": "item", "value": saint_patrick_page.id, }, ] ) ) ), [ (Page, str(christmas_page.id), "item", "block1"), (Page, str(saint_patrick_page.id), "item", "block2"), ], ) class TestStreamBlock(WagtailTestUtils, SimpleTestCase): def test_initialisation(self): block = blocks.StreamBlock( [ ("heading", blocks.CharBlock()), ("paragraph", blocks.CharBlock()), ] ) self.assertEqual(list(block.child_blocks.keys()), ["heading", "paragraph"]) def test_initialisation_with_binary_string_names(self): # migrations will sometimes write out names as binary strings, just to keep us on our toes block = blocks.StreamBlock( [ (b"heading", blocks.CharBlock()), (b"paragraph", blocks.CharBlock()), ] ) self.assertEqual(list(block.child_blocks.keys()), [b"heading", b"paragraph"]) def test_initialisation_from_subclass(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock() self.assertEqual(list(block.child_blocks.keys()), ["heading", "paragraph"]) def test_initialisation_from_subclass_with_extra(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock([("intro", blocks.CharBlock())]) self.assertEqual( list(block.child_blocks.keys()), ["heading", "paragraph", "intro"] ) def test_initialisation_with_multiple_subclassses(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() class ArticleWithIntroBlock(ArticleBlock): intro = blocks.CharBlock() block = ArticleWithIntroBlock() self.assertEqual( list(block.child_blocks.keys()), ["heading", "paragraph", "intro"] ) def test_initialisation_with_mixins(self): """ The order of child blocks of a ``StreamBlock`` with multiple parent classes is slightly surprising at first. Child blocks are inherited in a bottom-up order, by traversing the MRO in reverse. In the example below, ``ArticleWithIntroBlock`` will have an MRO of:: [ArticleWithIntroBlock, IntroMixin, ArticleBlock, StreamBlock, ...] This will result in ``intro`` appearing *after* ``heading`` and ``paragraph`` in ``ArticleWithIntroBlock.child_blocks``, even though ``IntroMixin`` appeared before ``ArticleBlock``. """ class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() class IntroMixin(blocks.StreamBlock): intro = blocks.CharBlock() class ArticleWithIntroBlock(IntroMixin, ArticleBlock): by_line = blocks.CharBlock() block = ArticleWithIntroBlock() self.assertEqual( list(block.child_blocks.keys()), ["heading", "paragraph", "intro", "by_line"], ) def test_field_has_changed(self): block = blocks.StreamBlock([("paragraph", blocks.CharBlock())]) initial_value = blocks.StreamValue(block, [("paragraph", "test")]) initial_value[0].id = "a" data_value = blocks.StreamValue(block, [("paragraph", "test")]) data_value[0].id = "a" # identical ids and content, so has_changed should return False self.assertFalse( blocks.BlockField(block).has_changed(initial_value, data_value) ) changed_data_value = blocks.StreamValue(block, [("paragraph", "not a test")]) changed_data_value[0].id = "a" # identical ids but changed content, so has_changed should return True self.assertTrue( blocks.BlockField(block).has_changed(initial_value, changed_data_value) ) def test_required_raises_an_exception_if_empty(self): block = blocks.StreamBlock([("paragraph", blocks.CharBlock())], required=True) value = blocks.StreamValue(block, []) with self.assertRaises(blocks.StreamBlockValidationError): block.clean(value) def test_required_does_not_raise_an_exception_if_not_empty(self): block = blocks.StreamBlock([("paragraph", blocks.CharBlock())], required=True) value = block.to_python([{"type": "paragraph", "value": "Hello"}]) try: block.clean(value) except blocks.StreamBlockValidationError: raise self.failureException( "%s was raised" % blocks.StreamBlockValidationError ) def test_not_required_does_not_raise_an_exception_if_empty(self): block = blocks.StreamBlock([("paragraph", blocks.CharBlock())], required=False) value = blocks.StreamValue(block, []) try: block.clean(value) except blocks.StreamBlockValidationError: raise self.failureException( "%s was raised" % blocks.StreamBlockValidationError ) def test_required_by_default(self): block = blocks.StreamBlock([("paragraph", blocks.CharBlock())]) value = blocks.StreamValue(block, []) with self.assertRaises(blocks.StreamBlockValidationError): block.clean(value) def render_article(self, data): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.RichTextBlock() block = ArticleBlock() value = block.to_python(data) return block.render(value) def test_get_api_representation_calls_same_method_on_children_with_context(self): """ The get_api_representation method of a StreamBlock should invoke the block's get_api_representation method on each child and the context should be passed on. """ class ContextBlock(blocks.CharBlock): def get_api_representation(self, value, context=None): return context[value] block = blocks.StreamBlock( [ ("language", ContextBlock()), ("author", ContextBlock()), ] ) api_representation = block.get_api_representation( block.to_python( [ {"type": "language", "value": "en"}, {"type": "author", "value": "wagtail", "id": "111111"}, ] ), context={"en": "English", "wagtail": "Wagtail!"}, ) self.assertListEqual( api_representation, [ {"type": "language", "value": "English", "id": None}, {"type": "author", "value": "Wagtail!", "id": "111111"}, ], ) def test_render(self): html = self.render_article( [ { "type": "heading", "value": "My title", }, { "type": "paragraph", "value": "My first paragraph", }, { "type": "paragraph", "value": "My second paragraph", }, ] ) self.assertIn('
    My title
    ', html) self.assertIn( '
    My first paragraph
    ', html ) self.assertIn('
    My second paragraph
    ', html) def test_render_unknown_type(self): # This can happen if a developer removes a type from their StreamBlock html = self.render_article( [ { "type": "foo", "value": "Hello", }, { "type": "paragraph", "value": "My first paragraph", }, ] ) self.assertNotIn("foo", html) self.assertNotIn("Hello", html) self.assertIn('
    My first paragraph
    ', html) def test_render_calls_block_render_on_children(self): """ The default rendering of a StreamBlock should invoke the block's render method on each child, rather than just outputting the child value as a string. """ block = blocks.StreamBlock( [ ( "heading", blocks.CharBlock(template="tests/blocks/heading_block.html"), ), ("paragraph", blocks.CharBlock()), ] ) value = block.to_python([{"type": "heading", "value": "Hello"}]) html = block.render(value) self.assertIn('

    Hello

    ', html) # calling render_as_block() on value (a StreamValue instance) # should be equivalent to block.render(value) html = value.render_as_block() self.assertIn('

    Hello

    ', html) def test_render_passes_context_to_children(self): block = blocks.StreamBlock( [ ( "heading", blocks.CharBlock(template="tests/blocks/heading_block.html"), ), ("paragraph", blocks.CharBlock()), ] ) value = block.to_python([{"type": "heading", "value": "Bonjour"}]) html = block.render( value, context={ "language": "fr", }, ) self.assertIn( '

    Bonjour

    ', html ) # calling render_as_block(context=foo) on value (a StreamValue instance) # should be equivalent to block.render(value, context=foo) html = value.render_as_block( context={ "language": "fr", } ) self.assertIn( '

    Bonjour

    ', html ) def test_render_on_stream_child_uses_child_template(self): """ Accessing a child element of the stream (giving a StreamChild object) and rendering it should use the block template, not just render the value's string representation """ block = blocks.StreamBlock( [ ( "heading", blocks.CharBlock(template="tests/blocks/heading_block.html"), ), ("paragraph", blocks.CharBlock()), ] ) value = block.to_python([{"type": "heading", "value": "Hello"}]) html = value[0].render() self.assertEqual("

    Hello

    ", html) # StreamChild.__str__ should do the same html = str(value[0]) self.assertEqual("

    Hello

    ", html) # and so should StreamChild.render_as_block html = value[0].render_as_block() self.assertEqual("

    Hello

    ", html) def test_can_pass_context_to_stream_child_template(self): block = blocks.StreamBlock( [ ( "heading", blocks.CharBlock(template="tests/blocks/heading_block.html"), ), ("paragraph", blocks.CharBlock()), ] ) value = block.to_python([{"type": "heading", "value": "Bonjour"}]) html = value[0].render(context={"language": "fr"}) self.assertEqual('

    Bonjour

    ', html) # the same functionality should be available through the alias `render_as_block` html = value[0].render_as_block(context={"language": "fr"}) self.assertEqual('

    Bonjour

    ', html) def test_adapt(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock() block.set_name("test_streamblock") js_args = StreamBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_streamblock") # convert group_by iterable into a list grouped_blocks = [ (group_name, list(group_iter)) for (group_name, group_iter) in js_args[1] ] self.assertEqual(len(grouped_blocks), 1) group_name, block_iter = grouped_blocks[0] self.assertEqual(group_name, "") block_list = list(block_iter) self.assertIsInstance(block_list[0], blocks.CharBlock) self.assertEqual(block_list[0].name, "heading") self.assertIsInstance(block_list[1], blocks.CharBlock) self.assertEqual(block_list[1].name, "paragraph") self.assertEqual(js_args[2], {"heading": None, "paragraph": None}) self.assertEqual( js_args[3], { "label": "Test streamblock", "description": "", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": None, "collapsed": False, "maxNum": None, "minNum": None, "blockCounts": {}, "required": True, "strings": { "DELETE": "Delete", "DUPLICATE": "Duplicate", "MOVE_DOWN": "Move down", "MOVE_UP": "Move up", "DRAG": "Drag", "ADD": "Add", }, }, ) def test_value_omitted_from_data(self): block = blocks.StreamBlock( [ ("heading", blocks.CharBlock()), ] ) # overall value is considered present in the form if the 'count' field is present self.assertFalse( block.value_omitted_from_data({"mystream-count": "0"}, {}, "mystream") ) self.assertFalse( block.value_omitted_from_data( { "mystream-count": "1", "mystream-0-type": "heading", "mystream-0-value": "hello", "mystream-0-deleted": "", "mystream-0-order": "0", }, {}, "mystream", ) ) self.assertTrue( block.value_omitted_from_data({"nothing-here": "nope"}, {}, "mystream") ) def test_validation_errors(self): class ValidatedBlock(blocks.StreamBlock): char = blocks.CharBlock() url = blocks.URLBlock() block = ValidatedBlock() value = blocks.StreamValue( block, [ ("char", ""), ("char", "foo"), ("url", "http://example.com/"), ("url", "not a url"), ], ) with self.assertRaises(ValidationError) as catcher: block.clean(value) self.assertEqual( catcher.exception.as_json_data(), { "blockErrors": { 0: {"messages": ["This field is required."]}, 3: {"messages": ["Enter a valid URL."]}, } }, ) def test_min_num_validation_errors(self): class ValidatedBlock(blocks.StreamBlock): char = blocks.CharBlock() url = blocks.URLBlock() block = ValidatedBlock(min_num=1) value = blocks.StreamValue(block, []) with self.assertRaises(ValidationError) as catcher: block.clean(value) self.assertEqual( catcher.exception.as_json_data(), {"messages": ["The minimum number of items is 1"]}, ) # a value with >= 1 blocks should pass validation value = blocks.StreamValue(block, [("char", "foo")]) self.assertTrue(block.clean(value)) def test_max_num_validation_errors(self): class ValidatedBlock(blocks.StreamBlock): char = blocks.CharBlock() url = blocks.URLBlock() block = ValidatedBlock(max_num=1) value = blocks.StreamValue( block, [ ("char", "foo"), ("char", "foo"), ("url", "http://example.com/"), ("url", "http://example.com/"), ], ) with self.assertRaises(ValidationError) as catcher: block.clean(value) self.assertEqual( catcher.exception.as_json_data(), {"messages": ["The maximum number of items is 1"]}, ) # a value with 1 block should pass validation value = blocks.StreamValue(block, [("char", "foo")]) self.assertTrue(block.clean(value)) def test_block_counts_min_validation_errors(self): class ValidatedBlock(blocks.StreamBlock): char = blocks.CharBlock() url = blocks.URLBlock() block = ValidatedBlock(block_counts={"char": {"min_num": 1}}) value = blocks.StreamValue( block, [ ("url", "http://example.com/"), ("url", "http://example.com/"), ], ) with self.assertRaises(ValidationError) as catcher: block.clean(value) self.assertEqual( catcher.exception.as_json_data(), {"messages": ["Char: The minimum number of items is 1"]}, ) # a value with 1 char block should pass validation value = blocks.StreamValue( block, [ ("url", "http://example.com/"), ("char", "foo"), ("url", "http://example.com/"), ], ) self.assertTrue(block.clean(value)) def test_block_counts_max_validation_errors(self): class ValidatedBlock(blocks.StreamBlock): char = blocks.CharBlock() url = blocks.URLBlock() block = ValidatedBlock(block_counts={"char": {"max_num": 1}}) value = blocks.StreamValue( block, [ ("char", "foo"), ("char", "foo"), ("url", "http://example.com/"), ("url", "http://example.com/"), ], ) with self.assertRaises(ValidationError) as catcher: block.clean(value) self.assertEqual( catcher.exception.as_json_data(), {"messages": ["Char: The maximum number of items is 1"]}, ) # a value with 1 char block should pass validation value = blocks.StreamValue( block, [ ("char", "foo"), ("url", "http://example.com/"), ("url", "http://example.com/"), ], ) self.assertTrue(block.clean(value)) def test_ordering_in_form_submission_uses_order_field(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock() # check that items are ordered by the 'order' field, not the order they appear in the form post_data = {"article-count": "3"} for i in range(0, 3): post_data.update( { "article-%d-deleted" % i: "", "article-%d-order" % i: str(2 - i), "article-%d-type" % i: "heading", "article-%d-value" % i: "heading %d" % i, "article-%d-id" % i: "000%d" % i, } ) block_value = block.value_from_datadict(post_data, {}, "article") self.assertEqual(block_value[2].value, "heading 0") self.assertEqual(block_value[2].id, "0000") def test_ordering_in_form_submission_is_numeric(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock() # check that items are ordered by 'order' numerically, not alphabetically post_data = {"article-count": "12"} for i in range(0, 12): post_data.update( { "article-%d-deleted" % i: "", "article-%d-order" % i: str(i), "article-%d-type" % i: "heading", "article-%d-value" % i: "heading %d" % i, } ) block_value = block.value_from_datadict(post_data, {}, "article") self.assertEqual(block_value[2].value, "heading 2") def test_searchable_content(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock() value = block.to_python( [ { "type": "heading", "value": "My title", }, { "type": "paragraph", "value": "My first paragraph", }, { "type": "paragraph", "value": "My second paragraph", }, ] ) content = block.get_searchable_content(value) self.assertEqual( content, [ "My title", "My first paragraph", "My second paragraph", ], ) def test_meta_default(self): """Test that we can specify a default value in the Meta of a StreamBlock""" class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() class Meta: default = [("heading", "A default heading")] # to access the default value, we retrieve it through a StructBlock # from a struct value that's missing that key class ArticleContainerBlock(blocks.StructBlock): author = blocks.CharBlock() article = ArticleBlock() block = ArticleContainerBlock() struct_value = block.to_python({"author": "Bob"}) stream_value = struct_value["article"] self.assertIsInstance(stream_value, blocks.StreamValue) self.assertEqual(len(stream_value), 1) self.assertEqual(stream_value[0].block_type, "heading") self.assertEqual(stream_value[0].value, "A default heading") def test_constructor_default(self): """Test that we can specify a default value in the constructor of a StreamBlock""" class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() class Meta: default = [("heading", "A default heading")] # to access the default value, we retrieve it through a StructBlock # from a struct value that's missing that key class ArticleContainerBlock(blocks.StructBlock): author = blocks.CharBlock() article = ArticleBlock(default=[("heading", "A different default heading")]) block = ArticleContainerBlock() struct_value = block.to_python({"author": "Bob"}) stream_value = struct_value["article"] self.assertIsInstance(stream_value, blocks.StreamValue) self.assertEqual(len(stream_value), 1) self.assertEqual(stream_value[0].block_type, "heading") self.assertEqual(stream_value[0].value, "A different default heading") def test_stream_value_equality(self): block = blocks.StreamBlock( [ ("text", blocks.CharBlock()), ] ) value1 = block.to_python([{"type": "text", "value": "hello"}]) value2 = block.to_python([{"type": "text", "value": "hello"}]) value3 = block.to_python([{"type": "text", "value": "goodbye"}]) self.assertEqual(value1, value2) self.assertNotEqual(value1, value3) def test_adapt_considers_group_attribute(self): """If group attributes are set in Block Meta classes, make sure the blocks are grouped together""" class Group1Block1(blocks.CharBlock): class Meta: group = "group1" class Group1Block2(blocks.CharBlock): class Meta: group = "group1" class Group2Block1(blocks.CharBlock): class Meta: group = "group2" class Group2Block2(blocks.CharBlock): class Meta: group = "group2" class NoGroupBlock(blocks.CharBlock): pass block = blocks.StreamBlock( [ ("b1", Group1Block1()), ("b2", Group1Block2()), ("b3", Group2Block1()), ("b4", Group2Block2()), ("ngb", NoGroupBlock()), ] ) block.set_name("test_streamblock") js_args = StreamBlockAdapter().js_args(block) blockdefs_dict = dict(js_args[1]) self.assertEqual(blockdefs_dict.keys(), {"", "group1", "group2"}) def test_value_from_datadict(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock() value = block.value_from_datadict( { "foo-count": "3", "foo-0-deleted": "", "foo-0-order": "2", "foo-0-type": "heading", "foo-0-id": "0000", "foo-0-value": "this is my heading", "foo-1-deleted": "1", "foo-1-order": "1", "foo-1-type": "heading", "foo-1-id": "0001", "foo-1-value": "a deleted heading", "foo-2-deleted": "", "foo-2-order": "0", "foo-2-type": "paragraph", "foo-2-id": "", "foo-2-value": "

    this is a paragraph

    ", }, {}, prefix="foo", ) self.assertEqual(len(value), 2) self.assertEqual(value[0].block_type, "paragraph") self.assertEqual(value[0].id, "") self.assertEqual(value[0].value, "

    this is a paragraph

    ") self.assertEqual(value[1].block_type, "heading") self.assertEqual(value[1].id, "0000") self.assertEqual(value[1].value, "this is my heading") def check_get_prep_value(self, stream_data, is_lazy): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.CharBlock() block = ArticleBlock() value = blocks.StreamValue(block, stream_data, is_lazy=is_lazy) jsonish_value = block.get_prep_value(value) self.assertEqual(len(jsonish_value), 2) self.assertEqual( jsonish_value[0], {"type": "heading", "value": "this is my heading", "id": "0000"}, ) self.assertEqual(jsonish_value[1]["type"], "paragraph") self.assertEqual(jsonish_value[1]["value"], "

    this is a paragraph

    ") # get_prep_value should assign a new (random and non-empty) # ID to this block, as it didn't have one already. self.assertTrue(jsonish_value[1]["id"]) # Calling get_prep_value again should preserve existing IDs, including the one # just assigned to block 1 jsonish_value_again = block.get_prep_value(value) self.assertEqual(jsonish_value[0]["id"], jsonish_value_again[0]["id"]) self.assertEqual(jsonish_value[1]["id"], jsonish_value_again[1]["id"]) def test_get_prep_value_not_lazy(self): stream_data = [ ("heading", "this is my heading", "0000"), ("paragraph", "

    this is a paragraph

    "), ] self.check_get_prep_value(stream_data, is_lazy=False) def test_get_prep_value_is_lazy(self): stream_data = [ {"type": "heading", "value": "this is my heading", "id": "0000"}, {"type": "paragraph", "value": "

    this is a paragraph

    "}, ] self.check_get_prep_value(stream_data, is_lazy=True) def check_get_prep_value_nested_streamblocks(self, stream_data, is_lazy): class TwoColumnBlock(blocks.StructBlock): left = blocks.StreamBlock([("text", blocks.CharBlock())]) right = blocks.StreamBlock([("text", blocks.CharBlock())]) block = TwoColumnBlock() value = { k: blocks.StreamValue(block.child_blocks[k], v, is_lazy=is_lazy) for k, v in stream_data.items() } jsonish_value = block.get_prep_value(value) self.assertEqual(len(jsonish_value), 2) self.assertEqual( jsonish_value["left"], [{"type": "text", "value": "some text", "id": "0000"}], ) self.assertEqual(len(jsonish_value["right"]), 1) right_block = jsonish_value["right"][0] self.assertEqual(right_block["type"], "text") self.assertEqual(right_block["value"], "some other text") # get_prep_value should assign a new (random and non-empty) # ID to this block, as it didn't have one already. self.assertTrue(right_block["id"]) def test_get_prep_value_nested_streamblocks_not_lazy(self): stream_data = { "left": [("text", "some text", "0000")], "right": [("text", "some other text")], } self.check_get_prep_value_nested_streamblocks(stream_data, is_lazy=False) def test_get_prep_value_nested_streamblocks_is_lazy(self): stream_data = { "left": [ { "type": "text", "value": "some text", "id": "0000", } ], "right": [ { "type": "text", "value": "some other text", } ], } self.check_get_prep_value_nested_streamblocks(stream_data, is_lazy=True) def test_modifications_to_stream_child_id_are_saved(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"}, ] ) stream[1].id = "0003" raw_data = block.get_prep_value(stream) self.assertEqual( raw_data, [ {"type": "heading", "value": "hello", "id": "0001"}, {"type": "paragraph", "value": "world", "id": "0003"}, ], ) def test_modifications_to_stream_child_value_are_saved(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"}, ] ) stream[1].value = "earth" raw_data = block.get_prep_value(stream) self.assertEqual( raw_data, [ {"type": "heading", "value": "hello", "id": "0001"}, {"type": "paragraph", "value": "earth", "id": "0002"}, ], ) def test_set_streamvalue_item(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"}, ] ) stream[1] = ("heading", "goodbye", "0003") raw_data = block.get_prep_value(stream) self.assertEqual( raw_data, [ {"type": "heading", "value": "hello", "id": "0001"}, {"type": "heading", "value": "goodbye", "id": "0003"}, ], ) def test_delete_streamvalue_item(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"}, ] ) del stream[0] raw_data = block.get_prep_value(stream) self.assertEqual( raw_data, [ {"type": "paragraph", "value": "world", "id": "0002"}, ], ) def test_insert_streamvalue_item(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"}, ] ) stream.insert(1, ("paragraph", "mutable", "0003")) raw_data = block.get_prep_value(stream) self.assertEqual( raw_data, [ {"type": "heading", "value": "hello", "id": "0001"}, {"type": "paragraph", "value": "mutable", "id": "0003"}, {"type": "paragraph", "value": "world", "id": "0002"}, ], ) def test_append_streamvalue_item(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"}, ] ) stream.append(("paragraph", "of warcraft", "0003")) raw_data = block.get_prep_value(stream) self.assertEqual( raw_data, [ {"type": "heading", "value": "hello", "id": "0001"}, {"type": "paragraph", "value": "world", "id": "0002"}, {"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_adapt_with_classname_via_kwarg(self): """form_classname from kwargs to be used as an additional class when rendering stream block""" block = blocks.StreamBlock( [ (b"heading", blocks.CharBlock()), (b"paragraph", blocks.CharBlock()), ], form_classname="rocket-section", ) block.set_name("test_streamblock") js_args = StreamBlockAdapter().js_args(block) self.assertEqual( js_args[3], { "label": "Test streamblock", "description": "", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "minNum": None, "maxNum": None, "blockCounts": {}, "collapsed": False, "required": True, "classname": "rocket-section", "strings": { "DELETE": "Delete", "DUPLICATE": "Duplicate", "MOVE_DOWN": "Move down", "MOVE_UP": "Move up", "DRAG": "Drag", "ADD": "Add", }, }, ) def test_block_names(self): class ArticleBlock(blocks.StreamBlock): heading = blocks.CharBlock() paragraph = blocks.TextBlock() date = blocks.DateBlock() block = ArticleBlock() value = block.to_python( [ { "type": "heading", "value": "My title", }, { "type": "paragraph", "value": "My first paragraph", }, { "type": "paragraph", "value": "My second paragraph", }, ] ) blocks_by_name = value.blocks_by_name() assert isinstance(blocks_by_name, blocks.StreamValue.BlockNameLookup) # unpack results to a dict of {block name: list of block values} for easier comparison result = { block_name: [block.value for block in blocks] for block_name, blocks in blocks_by_name.items() } self.assertEqual( result, { "heading": ["My title"], "paragraph": ["My first paragraph", "My second paragraph"], "date": [], }, ) paragraph_blocks = value.blocks_by_name(block_name="paragraph") # We can also access by indexing on the stream self.assertEqual(paragraph_blocks, value.blocks_by_name()["paragraph"]) self.assertEqual(len(paragraph_blocks), 2) for block in paragraph_blocks: self.assertEqual(block.block_type, "paragraph") self.assertEqual(value.blocks_by_name(block_name="date"), []) self.assertEqual(value.blocks_by_name(block_name="invalid_type"), []) first_heading_block = value.first_block_by_name(block_name="heading") self.assertEqual(first_heading_block.block_type, "heading") self.assertEqual(first_heading_block.value, "My title") self.assertIs(value.first_block_by_name(block_name="date"), None) self.assertIs(value.first_block_by_name(block_name="invalid_type"), None) # first_block_by_name with no argument returns a dict-like lookup of first blocks per name first_blocks_by_name = value.first_block_by_name() first_heading_block = first_blocks_by_name["heading"] self.assertEqual(first_heading_block.block_type, "heading") self.assertEqual(first_heading_block.value, "My title") self.assertIs(first_blocks_by_name["date"], None) self.assertIs(first_blocks_by_name["invalid_type"], None) def test_adapt_with_classname_via_class_meta(self): """form_classname from meta to be used as an additional class when rendering stream block""" class ProfileBlock(blocks.StreamBlock): username = blocks.CharBlock() class Meta: form_classname = "profile-block-large" block = ProfileBlock() block.set_name("test_streamblock") js_args = StreamBlockAdapter().js_args(block) self.assertEqual( js_args[3], { "label": "Test streamblock", "description": "", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "minNum": None, "maxNum": None, "blockCounts": {}, "collapsed": False, "required": True, "classname": "profile-block-large", "strings": { "DELETE": "Delete", "DUPLICATE": "Duplicate", "MOVE_DOWN": "Move down", "MOVE_UP": "Move up", "DRAG": "Drag", "ADD": "Add", }, }, ) class TestNormalizeStreamBlock(SimpleTestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.simple_block = blocks.StreamBlock( [("number", blocks.IntegerBlock()), ("text", blocks.TextBlock())] ) cls.recursive_block = blocks.StreamBlock( [ ( "inner_stream", blocks.StreamBlock( [ ("number", blocks.IntegerBlock()), ("text", blocks.TextBlock()), ("inner_list", blocks.ListBlock(blocks.IntegerBlock)), ] ), ), ("struct", blocks.StructBlock([("bool", blocks.BooleanBlock())])), ("list", blocks.ListBlock(blocks.IntegerBlock)), ] ) def test_normalize_empty_stream(self): values = [[], "", None] for value in values: with self.subTest(value=value): self.assertEqual( self.simple_block.normalize(value), blocks.StreamValue(self.simple_block, []), ) def test_normalize_base_case(self): """ Test normalize when trivially recursive, or already a StreamValue """ value = [("number", 1), ("text", "ichiban")] stream_value = blocks.StreamValue(self.simple_block, value) self.assertEqual(stream_value, self.simple_block.normalize(value)) self.assertEqual(stream_value, self.simple_block.normalize(stream_value)) def test_normalize_recursive(self): """ A stream block is normalized iff all of its sub-blocks are normalized. """ values = ( # A smart, "list of tuples" representation [ ("struct", {"bool": True}), ( "inner_stream", [ ("number", 1), ("text", "one"), ("inner_list", [0, 1, 1, 2, 3, 5]), ], ), ("list", [0, 1, 1, 2, 3, 5]), ], # A json-ish representation - the serialized format [ {"type": "struct", "value": {"bool": True}}, { "type": "inner_stream", "value": [ {"type": "number", "value": 1}, {"type": "text", "value": "one"}, { "type": "inner_list", "value": [ # Unlike StreamBlock, ListBlock requires that its items # have IDs, to distinguish the new serialization format # from the old. {"type": "item", "value": 0, "id": 1}, {"type": "item", "value": 1, "id": 2}, {"type": "item", "value": 2, "id": 3}, ], }, ], }, { "type": "list", "value": [ {"type": "item", "value": 0, "id": 1}, {"type": "item", "value": 1, "id": 2}, {"type": "item", "value": 2, "id": 3}, ], }, ], ) for value in values: with self.subTest(value=value): # Normalize the value. normalized = self.recursive_block.normalize(value) # Then check all of the sub-blocks have been normalized: # the StructBlock child self.assertIsInstance(normalized[0].value, blocks.StructValue) self.assertIsInstance(normalized[0].value["bool"], bool) # the nested StreamBlock child self.assertIsInstance(normalized[1].value, blocks.StreamValue) self.assertIsInstance(normalized[1].value[0].value, int) self.assertIsInstance(normalized[1].value[1].value, str) # the ListBlock child self.assertIsInstance(normalized[2].value[0], int) self.assertIsInstance(normalized[2].value, blocks.list_block.ListValue) # the inner ListBlock nested in the nested streamblock self.assertIsInstance(normalized[1].value[2].value[0], int) self.assertIsInstance( normalized[1].value[2].value, blocks.list_block.ListValue ) class TestStructBlockWithFixtures(TestCase): fixtures = ["test.json"] def test_bulk_to_python(self): page_link_block = blocks.StructBlock( [ ("page", blocks.PageChooserBlock(required=False)), ("link_text", blocks.CharBlock(default="missing title")), ] ) with self.assertNumQueries(1): result = page_link_block.bulk_to_python( [ {"page": 2, "link_text": "page two"}, {"page": 3, "link_text": "page three"}, {"page": None, "link_text": "no page"}, {"page": 4}, ] ) result_types = [type(val) for val in result] self.assertEqual(result_types, [blocks.StructValue] * 4) result_titles = [val["link_text"] for val in result] self.assertEqual( result_titles, ["page two", "page three", "no page", "missing title"] ) result_pages = [val["page"] for val in result] self.assertEqual( result_pages, [ Page.objects.get(id=2), Page.objects.get(id=3), None, Page.objects.get(id=4), ], ) def test_extract_references(self): block = blocks.StructBlock( [ ("page", blocks.PageChooserBlock(required=False)), ("link_text", blocks.CharBlock(default="missing title")), ] ) christmas_page = Page.objects.get(slug="christmas") self.assertListEqual( list( block.extract_references( {"page": christmas_page, "link_text": "Christmas"} ) ), [ (Page, str(christmas_page.id), "page", "page"), ], ) class TestStreamBlockWithFixtures(TestCase): fixtures = ["test.json"] def test_bulk_to_python(self): stream_block = blocks.StreamBlock( [ ("page", blocks.PageChooserBlock()), ("heading", blocks.CharBlock()), ] ) # The naive implementation of bulk_to_python (calling to_python on each item) would perform # NO queries, as StreamBlock.to_python returns a lazy StreamValue that only starts calling # to_python on its children (and thus triggering DB queries) when its items are accessed. # This is a good thing for a standalone to_python call, because loading a model instance # with a StreamField in it will immediately call StreamField.to_python which in turn calls # to_python on the top-level StreamBlock, and we really don't want # SomeModelWithAStreamField.objects.get(id=1) to immediately trigger a cascading fetch of # all objects referenced in the StreamField. # # However, for bulk_to_python that's bad, as it means each stream in the list would end up # doing its own object lookups in isolation, missing the opportunity to group them together # into a single call to the child block's bulk_to_python. Therefore, the ideal outcome is # that we perform one query now (covering all PageChooserBlocks across all streams), # returning a list of non-lazy StreamValues. with self.assertNumQueries(1): results = stream_block.bulk_to_python( [ [ {"type": "heading", "value": "interesting pages"}, {"type": "page", "value": 2}, {"type": "page", "value": 3}, ], [ {"type": "heading", "value": "pages written by dogs"}, {"type": "woof", "value": "woof woof"}, ], [ {"type": "heading", "value": "boring pages"}, {"type": "page", "value": 4}, ], ] ) # If bulk_to_python has indeed given us non-lazy StreamValues, then no further queries # should be performed when iterating over its child blocks. with self.assertNumQueries(0): block_types = [[block.block_type for block in stream] for stream in results] self.assertEqual( block_types, [ ["heading", "page", "page"], ["heading"], ["heading", "page"], ], ) with self.assertNumQueries(0): block_values = [[block.value for block in stream] for stream in results] self.assertEqual( block_values, [ ["interesting pages", Page.objects.get(id=2), Page.objects.get(id=3)], ["pages written by dogs"], ["boring pages", Page.objects.get(id=4)], ], ) def test_extract_references(self): block = blocks.StreamBlock( [ ("page", blocks.PageChooserBlock()), ("heading", blocks.CharBlock()), ] ) christmas_page = Page.objects.get(slug="christmas") saint_patrick_page = Page.objects.get(slug="saint-patrick") self.assertListEqual( list( block.extract_references( block.to_python( [ { "id": "block1", "type": "heading", "value": "Some events that you might like", }, { "id": "block2", "type": "page", "value": christmas_page.id, }, { "id": "block3", "type": "page", "value": saint_patrick_page.id, }, ] ) ) ), [ (Page, str(christmas_page.id), "page", "block2"), (Page, str(saint_patrick_page.id), "page", "block3"), ], ) class TestPageChooserBlock(TestCase): fixtures = ["test.json"] def test_serialize(self): """The value of a PageChooserBlock (a Page object) should serialize to an ID""" block = blocks.PageChooserBlock() christmas_page = Page.objects.get(slug="christmas") self.assertEqual(block.get_prep_value(christmas_page), christmas_page.id) # None should serialize to None self.assertIsNone(block.get_prep_value(None)) def test_deserialize(self): """The serialized value of a PageChooserBlock (an ID) should deserialize to a Page object""" block = blocks.PageChooserBlock() christmas_page = Page.objects.get(slug="christmas") self.assertEqual(block.to_python(christmas_page.id), christmas_page) # None should deserialize to None self.assertIsNone(block.to_python(None)) def test_adapt(self): from wagtail.admin.widgets.chooser import AdminPageChooser block = blocks.PageChooserBlock( help_text="pick a page, any page", description="A featured page" ) block.set_name("test_pagechooserblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_pagechooserblock") self.assertIsInstance(js_args[1], AdminPageChooser) self.assertEqual(js_args[1].target_models, [Page]) self.assertFalse(js_args[1].can_choose_root) self.assertEqual( js_args[2], { "label": "Test pagechooserblock", "description": "A featured page", "required": True, "icon": "doc-empty-inverse", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "helpText": "pick a page, any page", "classname": "w-field w-field--model_choice_field w-field--admin_page_chooser", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_adapt_with_target_model_string(self): block = blocks.PageChooserBlock( help_text="pick a page, any page", page_type="tests.SimplePage" ) block.set_name("test_pagechooserblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[1].target_models, [SimplePage]) def test_adapt_with_target_model_literal(self): block = blocks.PageChooserBlock( help_text="pick a page, any page", page_type=SimplePage ) block.set_name("test_pagechooserblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[1].target_models, [SimplePage]) def test_adapt_with_target_model_multiple_strings(self): block = blocks.PageChooserBlock( help_text="pick a page, any page", page_type=["tests.SimplePage", "tests.EventPage"], ) block.set_name("test_pagechooserblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[1].target_models, [SimplePage, EventPage]) def test_adapt_with_target_model_multiple_literals(self): block = blocks.PageChooserBlock( help_text="pick a page, any page", page_type=[SimplePage, EventPage] ) block.set_name("test_pagechooserblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[1].target_models, [SimplePage, EventPage]) def test_adapt_with_can_choose_root(self): block = blocks.PageChooserBlock( help_text="pick a page, any page", can_choose_root=True ) block.set_name("test_pagechooserblock") js_args = FieldBlockAdapter().js_args(block) self.assertTrue(js_args[1].can_choose_root) def test_form_response(self): block = blocks.PageChooserBlock() christmas_page = Page.objects.get(slug="christmas") value = block.value_from_datadict({"page": str(christmas_page.id)}, {}, "page") self.assertEqual(value, christmas_page) empty_value = block.value_from_datadict({"page": ""}, {}, "page") self.assertIsNone(empty_value) def test_clean(self): required_block = blocks.PageChooserBlock() nonrequired_block = blocks.PageChooserBlock(required=False) christmas_page = Page.objects.get(slug="christmas") self.assertEqual(required_block.clean(christmas_page), christmas_page) with self.assertRaises(ValidationError): required_block.clean(None) self.assertEqual(nonrequired_block.clean(christmas_page), christmas_page) self.assertIsNone(nonrequired_block.clean(None)) def test_target_model_default(self): block = blocks.PageChooserBlock() self.assertEqual(block.target_model, Page) def test_target_model_string(self): block = blocks.PageChooserBlock(page_type="tests.SimplePage") self.assertEqual(block.target_model, SimplePage) def test_target_model_literal(self): block = blocks.PageChooserBlock(page_type=SimplePage) self.assertEqual(block.target_model, SimplePage) def test_target_model_multiple_strings(self): block = blocks.PageChooserBlock( page_type=["tests.SimplePage", "tests.EventPage"] ) self.assertEqual(block.target_model, Page) def test_target_model_multiple_literals(self): block = blocks.PageChooserBlock(page_type=[SimplePage, EventPage]) self.assertEqual(block.target_model, Page) def test_deconstruct_target_model_default(self): block = blocks.PageChooserBlock() self.assertEqual( block.deconstruct(), ("wagtail.blocks.PageChooserBlock", (), {}) ) def test_deconstruct_target_model_string(self): block = blocks.PageChooserBlock(page_type="tests.SimplePage") self.assertEqual( block.deconstruct(), ( "wagtail.blocks.PageChooserBlock", (), {"page_type": ["tests.SimplePage"]}, ), ) def test_deconstruct_target_model_literal(self): block = blocks.PageChooserBlock(page_type=SimplePage) self.assertEqual( block.deconstruct(), ( "wagtail.blocks.PageChooserBlock", (), {"page_type": ["tests.SimplePage"]}, ), ) def test_deconstruct_target_model_multiple_strings(self): block = blocks.PageChooserBlock( page_type=["tests.SimplePage", "tests.EventPage"] ) self.assertEqual( block.deconstruct(), ( "wagtail.blocks.PageChooserBlock", (), {"page_type": ["tests.SimplePage", "tests.EventPage"]}, ), ) def test_deconstruct_target_model_multiple_literals(self): block = blocks.PageChooserBlock(page_type=[SimplePage, EventPage]) self.assertEqual( block.deconstruct(), ( "wagtail.blocks.PageChooserBlock", (), {"page_type": ["tests.SimplePage", "tests.EventPage"]}, ), ) def test_bulk_to_python(self): page_ids = [2, 3, 4, 5] expected_pages = Page.objects.filter(pk__in=page_ids) block = blocks.PageChooserBlock() with self.assertNumQueries(1): pages = block.bulk_to_python(page_ids) self.assertSequenceEqual(pages, expected_pages) def test_bulk_to_python_distinct_instances(self): page_ids = [2, 2] block = blocks.PageChooserBlock() with self.assertNumQueries(1): pages = block.bulk_to_python(page_ids) # Ensure that the two retrieved pages are distinct instances self.assertIsNot(pages[0], pages[1]) def test_extract_references(self): block = blocks.PageChooserBlock() christmas_page = Page.objects.get(slug="christmas") self.assertListEqual( list(block.extract_references(christmas_page)), [(Page, str(christmas_page.id), "", "")], ) # None should not yield any references self.assertListEqual(list(block.extract_references(None)), []) class TestStaticBlock(unittest.TestCase): def test_adapt_with_constructor(self): block = blocks.StaticBlock( admin_text="Latest posts - This block doesn't need to be configured, it will be displayed automatically", template="tests/blocks/posts_static_block.html", ) block.set_name("posts_static_block") js_args = StaticBlockAdapter().js_args(block) self.assertEqual(js_args[0], "posts_static_block") self.assertEqual( js_args[1], { "text": "Latest posts - This block doesn't need to be configured, it will be displayed automatically", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "label": "Posts static block", "description": "", }, ) def test_adapt_with_subclass(self): class PostsStaticBlock(blocks.StaticBlock): class Meta: admin_text = "Latest posts - This block doesn't need to be configured, it will be displayed automatically" template = "tests/blocks/posts_static_block.html" block = PostsStaticBlock() block.set_name("posts_static_block") js_args = StaticBlockAdapter().js_args(block) self.assertEqual(js_args[0], "posts_static_block") self.assertEqual( js_args[1], { "text": "Latest posts - This block doesn't need to be configured, it will be displayed automatically", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "label": "Posts static block", "description": "", }, ) def test_adapt_with_subclass_displays_default_text_if_no_admin_text(self): class LabelOnlyStaticBlock(blocks.StaticBlock): class Meta: label = "Latest posts" block = LabelOnlyStaticBlock() block.set_name("posts_static_block") js_args = StaticBlockAdapter().js_args(block) self.assertEqual(js_args[0], "posts_static_block") self.assertEqual( js_args[1], { "text": "Latest posts: this block has no options.", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "label": "Latest posts", "description": "", }, ) def test_adapt_with_subclass_displays_default_text_if_no_admin_text_and_no_label( self, ): class NoMetaStaticBlock(blocks.StaticBlock): pass block = NoMetaStaticBlock() block.set_name("posts_static_block") js_args = StaticBlockAdapter().js_args(block) self.assertEqual(js_args[0], "posts_static_block") self.assertEqual( js_args[1], { "text": "Posts static block: this block has no options.", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "label": "Posts static block", "description": "", }, ) def test_adapt_works_with_mark_safe(self): block = blocks.StaticBlock( admin_text=mark_safe( "Latest posts - This block doesn't need to be configured, it will be displayed automatically" ), template="tests/blocks/posts_static_block.html", ) block.set_name("posts_static_block") js_args = StaticBlockAdapter().js_args(block) self.assertEqual(js_args[0], "posts_static_block") self.assertEqual( js_args[1], { "html": "Latest posts - This block doesn't need to be configured, it will be displayed automatically", "icon": "placeholder", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "label": "Posts static block", "description": "", }, ) def test_get_default(self): block = blocks.StaticBlock() default_value = block.get_default() self.assertIsNone(default_value) def test_render(self): block = blocks.StaticBlock(template="tests/blocks/posts_static_block.html") result = block.render(None) self.assertEqual(result, "

    PostsStaticBlock template

    ") def test_render_without_template(self): block = blocks.StaticBlock() result = block.render(None) self.assertEqual(result, "") def test_serialize(self): block = blocks.StaticBlock() result = block.get_prep_value(None) self.assertIsNone(result) def test_deserialize(self): block = blocks.StaticBlock() result = block.to_python(None) self.assertIsNone(result) def test_normalize(self): """ StaticBlock.normalize always returns None, as a StaticBlock has no value """ self.assertIsNone(blocks.StaticBlock().normalize(11)) class TestDateBlock(TestCase): def test_adapt(self): from wagtail.admin.widgets.datetime import AdminDateInput block = blocks.DateBlock() block.set_name("test_dateblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_dateblock") self.assertIsInstance(js_args[1], AdminDateInput) self.assertEqual(js_args[1].js_format, "Y-m-d") self.assertEqual( js_args[2], { "label": "Test dateblock", "description": "", "required": True, "icon": "date", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--date_field w-field--admin_date_input", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_adapt_with_format(self): block = blocks.DateBlock(format="%d.%m.%Y") block.set_name("test_dateblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[1].js_format, "d.m.Y") class TestTimeBlock(TestCase): def test_adapt(self): from wagtail.admin.widgets.datetime import AdminTimeInput block = blocks.TimeBlock() block.set_name("test_timeblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_timeblock") self.assertIsInstance(js_args[1], AdminTimeInput) self.assertEqual(js_args[1].js_format, "H:i") self.assertEqual( js_args[2], { "label": "Test timeblock", "description": "", "required": True, "icon": "time", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--time_field w-field--admin_time_input", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_adapt_with_format(self): block = blocks.TimeBlock(format="%H:%M:%S") block.set_name("test_timeblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[1].js_format, "H:i:s") class TestDateTimeBlock(TestCase): def test_adapt(self): from wagtail.admin.widgets.datetime import AdminDateTimeInput block = blocks.DateTimeBlock() block.set_name("test_datetimeblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[0], "test_datetimeblock") self.assertIsInstance(js_args[1], AdminDateTimeInput) self.assertEqual(js_args[1].js_format, "Y-m-d H:i") self.assertEqual( js_args[2], { "label": "Test datetimeblock", "description": "", "required": True, "icon": "date", "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "classname": "w-field w-field--date_time_field w-field--admin_date_time_input", "showAddCommentButton": True, "strings": {"ADD_COMMENT": "Add Comment"}, }, ) def test_adapt_with_format(self): block = blocks.DateTimeBlock(format="%d.%m.%Y %H:%M") block.set_name("test_datetimeblock") js_args = FieldBlockAdapter().js_args(block) self.assertEqual(js_args[1].js_format, "d.m.Y H:i") class TestSystemCheck(TestCase): def test_name_cannot_contain_non_alphanumeric(self): block = blocks.StreamBlock( [ ("heading", blocks.CharBlock()), ("rich+text", blocks.RichTextBlock()), ] ) errors = block.check() self.assertEqual(len(errors), 1) self.assertEqual(errors[0].id, "wagtailcore.E001") self.assertEqual( errors[0].hint, "Block names should follow standard Python conventions for variable names: alphanumeric and underscores, and cannot begin with a digit", ) self.assertEqual(errors[0].obj, block.child_blocks["rich+text"]) def test_name_must_be_nonempty(self): block = blocks.StreamBlock( [ ("heading", blocks.CharBlock()), ("", blocks.RichTextBlock()), ] ) errors = block.check() self.assertEqual(len(errors), 1) self.assertEqual(errors[0].id, "wagtailcore.E001") self.assertEqual(errors[0].hint, "Block name cannot be empty") self.assertEqual(errors[0].obj, block.child_blocks[""]) def test_name_cannot_contain_spaces(self): block = blocks.StreamBlock( [ ("heading", blocks.CharBlock()), ("rich text", blocks.RichTextBlock()), ] ) errors = block.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, block.child_blocks["rich text"]) def test_name_cannot_contain_dashes(self): block = blocks.StreamBlock( [ ("heading", blocks.CharBlock()), ("rich-text", blocks.RichTextBlock()), ] ) errors = block.check() self.assertEqual(len(errors), 1) self.assertEqual(errors[0].id, "wagtailcore.E001") self.assertEqual(errors[0].hint, "Block names cannot contain dashes") self.assertEqual(errors[0].obj, block.child_blocks["rich-text"]) def test_name_cannot_begin_with_digit(self): block = blocks.StreamBlock( [ ("heading", blocks.CharBlock()), ("99richtext", blocks.RichTextBlock()), ] ) errors = block.check() self.assertEqual(len(errors), 1) self.assertEqual(errors[0].id, "wagtailcore.E001") self.assertEqual(errors[0].hint, "Block names cannot begin with a digit") self.assertEqual(errors[0].obj, block.child_blocks["99richtext"]) def test_system_checks_recurse_into_lists(self): failing_block = blocks.RichTextBlock() block = blocks.StreamBlock( [ ( "paragraph_list", blocks.ListBlock( blocks.StructBlock( [ ("heading", blocks.CharBlock()), ("rich text", failing_block), ] ) ), ) ] ) errors = block.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, failing_block) def test_system_checks_recurse_into_streams(self): failing_block = blocks.RichTextBlock() block = blocks.StreamBlock( [ ( "carousel", blocks.StreamBlock( [ ( "text", blocks.StructBlock( [ ("heading", blocks.CharBlock()), ("rich text", failing_block), ] ), ) ] ), ) ] ) errors = block.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, failing_block) def test_system_checks_recurse_into_structs(self): failing_block_1 = blocks.RichTextBlock() failing_block_2 = blocks.RichTextBlock() block = blocks.StreamBlock( [ ( "two_column", blocks.StructBlock( [ ( "left", blocks.StructBlock( [ ("heading", blocks.CharBlock()), ("rich text", failing_block_1), ] ), ), ( "right", blocks.StructBlock( [ ("heading", blocks.CharBlock()), ("rich text", failing_block_2), ] ), ), ] ), ) ] ) errors = block.check() self.assertEqual(len(errors), 2) self.assertEqual(errors[0].id, "wagtailcore.E001") self.assertEqual(errors[0].hint, "Block names cannot contain spaces") self.assertEqual(errors[0].obj, failing_block_1) self.assertEqual(errors[1].id, "wagtailcore.E001") self.assertEqual(errors[1].hint, "Block names cannot contain spaces") self.assertEqual(errors[1].obj, failing_block_2) class TestTemplateRendering(TestCase): def test_render_with_custom_context(self): block = CustomLinkBlock() value = block.to_python({"title": "Torchbox", "url": "http://torchbox.com/"}) context = {"classname": "important"} result = block.render(value, context) self.assertEqual( result, 'Torchbox' ) @unittest.expectedFailure # TODO(telepath) def test_render_with_custom_form_context(self): block = CustomLinkBlock() value = block.to_python({"title": "Torchbox", "url": "http://torchbox.com/"}) result = block.render_form(value, prefix="my-link-block") self.assertIn('data-prefix="my-link-block"', result) self.assertIn("

    Hello from get_form_context!

    ", result) class TestIncludeBlockTag(TestCase): def test_include_block_tag_with_boundblock(self): """ The include_block tag should be able to render a BoundBlock's template while keeping the parent template's context """ block = blocks.CharBlock(template="tests/blocks/heading_block.html") bound_block = block.bind("bonjour") result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": bound_block, "language": "fr", }, ) self.assertIn('

    bonjour

    ', result) def test_include_block_tag_with_structvalue(self): """ The include_block tag should be able to render a StructValue's template while keeping the parent template's context """ block = SectionBlock() struct_value = block.to_python( {"title": "Bonjour", "body": "monde italique"} ) result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": struct_value, "language": "fr", }, ) self.assertIn( """

    Bonjour

    monde italique""", result ) def test_include_block_tag_with_streamvalue(self): """ The include_block tag should be able to render a StreamValue's template while keeping the parent template's context """ block = blocks.StreamBlock( [ ( "heading", blocks.CharBlock(template="tests/blocks/heading_block.html"), ), ("paragraph", blocks.CharBlock()), ], template="tests/blocks/stream_with_language.html", ) stream_value = block.to_python([{"type": "heading", "value": "Bonjour"}]) result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": stream_value, "language": "fr", }, ) self.assertIn( '

    Bonjour

    ', result ) def test_include_block_tag_with_plain_value(self): """ The include_block tag should be able to render a value without a render_as_block method by just rendering it as a string """ result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": 42, }, ) self.assertIn("42", result) def test_include_block_tag_with_filtered_value(self): """ The block parameter on include_block tag should support complex values including filters, e.g. {% include_block foo|default:123 %} """ block = blocks.CharBlock(template="tests/blocks/heading_block.html") bound_block = block.bind("bonjour") result = render_to_string( "tests/blocks/include_block_test_with_filter.html", { "test_block": bound_block, "language": "fr", }, ) self.assertIn('

    bonjour

    ', result) result = render_to_string( "tests/blocks/include_block_test_with_filter.html", { "test_block": None, "language": "fr", }, ) self.assertIn("999", result) def test_include_block_tag_with_extra_context(self): """ Test that it's possible to pass extra context on an include_block tag using {% include_block foo with classname="bar" %} """ block = blocks.CharBlock(template="tests/blocks/heading_block.html") bound_block = block.bind("bonjour") result = render_to_string( "tests/blocks/include_block_with_test.html", { "test_block": bound_block, "language": "fr", }, ) self.assertIn( '

    bonjour

    ', result ) def test_include_block_tag_with_only_flag(self): """ A tag such as {% include_block foo with classname="bar" only %} should not inherit the parent context """ block = blocks.CharBlock(template="tests/blocks/heading_block.html") bound_block = block.bind("bonjour") result = render_to_string( "tests/blocks/include_block_only_test.html", { "test_block": bound_block, "language": "fr", }, ) self.assertIn('

    bonjour

    ', result) def test_include_block_html_escaping(self): """ Output of include_block should be escaped as per Django autoescaping rules """ block = blocks.CharBlock() bound_block = block.bind(block.to_python("some evil HTML")) result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": bound_block, }, ) self.assertIn("some <em>evil</em> HTML", result) # {% autoescape off %} should be respected result = render_to_string( "tests/blocks/include_block_autoescape_off_test.html", { "test_block": bound_block, }, ) self.assertIn("some evil HTML", result) # The same escaping should be applied when passed a plain value rather than a BoundBlock - # a typical situation where this would occur would be rendering an item of a StructBlock, # e.g. {% include_block person_block.first_name %} as opposed to # {% include_block person_block.bound_blocks.first_name %} result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": "some evil HTML", }, ) self.assertIn("some <em>evil</em> HTML", result) result = render_to_string( "tests/blocks/include_block_autoescape_off_test.html", { "test_block": "some evil HTML", }, ) self.assertIn("some evil HTML", result) # Blocks that explicitly return 'safe HTML'-marked values (such as RawHTMLBlock) should # continue to produce unescaped output block = blocks.RawHTMLBlock() bound_block = block.bind(block.to_python("some evil HTML")) result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": bound_block, }, ) self.assertIn("some evil HTML", result) # likewise when applied to a plain 'safe HTML' value rather than a BoundBlock result = render_to_string( "tests/blocks/include_block_test.html", { "test_block": mark_safe("some evil HTML"), }, ) self.assertIn("some evil HTML", result) class TestOverriddenGetTemplateBlockTag(TestCase): def test_block_render_passes_the_value_argument_to_get_template(self): """verifies Block.render() passes the value to get_template""" class BlockChoosingTemplateBasedOnValue(blocks.Block): def get_template(self, value=None, context=None): if value == "HEADING": return "tests/blocks/heading_block.html" return None # using render_basic block = BlockChoosingTemplateBasedOnValue() html = block.render("Hello World") self.assertEqual(html, "Hello World") html = block.render("HEADING") self.assertEqual(html, "

    HEADING

    ") class TestValidationErrorAsJsonData(TestCase): def test_plain_validation_error(self): error = ValidationError("everything is broken") self.assertEqual( get_error_json_data(error), {"messages": ["everything is broken"]} ) def test_validation_error_with_multiple_messages(self): error = ValidationError( [ ValidationError("everything is broken"), ValidationError("even more broken than before"), ] ) self.assertEqual( get_error_json_data(error), {"messages": ["everything is broken", "even more broken than before"]}, ) def test_structblock_validation_error(self): error = StructBlockValidationError( block_errors={ "name": ErrorList( [ ValidationError("This field is required."), ] ) }, non_block_errors=ErrorList( [ValidationError("Either email or telephone number must be specified.")] ), ) self.assertEqual( get_error_json_data(error), { "blockErrors": { "name": {"messages": ["This field is required."]}, }, "messages": [ "Either email or telephone number must be specified.", ], }, ) def test_structblock_validation_error_with_no_block_errors(self): error = StructBlockValidationError( non_block_errors=[ ValidationError("Either email or telephone number must be specified.") ] ) self.assertEqual( get_error_json_data(error), { "messages": [ "Either email or telephone number must be specified.", ], }, ) def test_structblock_validation_error_with_no_non_block_errors(self): error = StructBlockValidationError( block_errors={ "name": ValidationError("This field is required."), }, ) self.assertEqual( get_error_json_data(error), { "blockErrors": { "name": {"messages": ["This field is required."]}, }, }, ) def test_streamblock_validation_error(self): error = StreamBlockValidationError( block_errors={ 2: ErrorList( [ StructBlockValidationError( non_block_errors=ErrorList( [ ValidationError( "Either email or telephone number must be specified." ) ] ) ) ] ), 4: ErrorList([ValidationError("This field is required.")]), 6: ErrorList( [ StructBlockValidationError( block_errors={ "name": ErrorList( [ValidationError("This field is required.")] ), } ) ] ), }, non_block_errors=ErrorList( [ ValidationError("The minimum number of items is 2"), ValidationError("The maximum number of items is 5"), ] ), ) self.assertEqual( get_error_json_data(error), { "blockErrors": { 2: { "messages": [ "Either email or telephone number must be specified." ] }, 4: {"messages": ["This field is required."]}, 6: { "blockErrors": { "name": { "messages": ["This field is required."], } } }, }, "messages": [ "The minimum number of items is 2", "The maximum number of items is 5", ], }, ) def test_streamblock_validation_error_with_no_block_errors(self): error = StreamBlockValidationError( non_block_errors=[ ValidationError("The minimum number of items is 2"), ], ) self.assertEqual( get_error_json_data(error), { "messages": [ "The minimum number of items is 2", ], }, ) def test_streamblock_validation_error_with_no_non_block_errors(self): error = StreamBlockValidationError( block_errors={ 4: [ValidationError("This field is required.")], 6: ValidationError("This field is required."), }, ) self.assertEqual( get_error_json_data(error), { "blockErrors": { 4: {"messages": ["This field is required."]}, 6: {"messages": ["This field is required."]}, } }, ) def test_listblock_validation_error_constructed_with_list(self): # test the pre-Wagtail-5.0 constructor format for ListBlockValidationError: # block_errors passed as a list with None for 'no error', and # a single-item ErrorList for validation errors error = ListBlockValidationError( block_errors=[ None, ErrorList( [ StructBlockValidationError( non_block_errors=ErrorList( [ ValidationError( "Either email or telephone number must be specified." ) ] ) ) ] ), ErrorList( [ StructBlockValidationError( block_errors={ "name": ErrorList( [ValidationError("This field is required.")] ), } ) ] ), ], non_block_errors=ErrorList( [ ValidationError("The minimum number of items is 2"), ValidationError("The maximum number of items is 5"), ] ), ) self.assertEqual( get_error_json_data(error), { "blockErrors": { 1: { "messages": [ "Either email or telephone number must be specified." ] }, 2: { "blockErrors": { "name": { "messages": ["This field is required."], } } }, }, "messages": [ "The minimum number of items is 2", "The maximum number of items is 5", ], }, ) def test_listblock_validation_error_constructed_with_dict(self): # test the Wagtail >=5.0 constructor format for ListBlockValidationError: # block_errors passed as a dict keyed by block index, where values can be # ValidationErrors and plain single-item lists as well as single-item ErrorLists error = ListBlockValidationError( block_errors={ 1: [ StructBlockValidationError( non_block_errors=ErrorList( [ ValidationError( "Either email or telephone number must be specified." ) ] ) ) ], 2: StructBlockValidationError( block_errors={ "name": ErrorList([ValidationError("This field is required.")]), } ), }, non_block_errors=ErrorList( [ ValidationError("The minimum number of items is 2"), ValidationError("The maximum number of items is 5"), ] ), ) self.assertEqual( get_error_json_data(error), { "blockErrors": { 1: { "messages": [ "Either email or telephone number must be specified." ] }, 2: { "blockErrors": { "name": { "messages": ["This field is required."], } } }, }, "messages": [ "The minimum number of items is 2", "The maximum number of items is 5", ], }, ) def test_listblock_validation_error_with_no_non_block_errors(self): error = ListBlockValidationError( block_errors={2: ValidationError("This field is required.")}, ) self.assertEqual( get_error_json_data(error), { "blockErrors": { 2: {"messages": ["This field is required."]}, }, }, ) def test_listblock_validation_error_with_no_block_errors(self): error = ListBlockValidationError( non_block_errors=[ ValidationError("The minimum number of items is 2"), ] ) self.assertEqual( get_error_json_data(error), { "messages": [ "The minimum number of items is 2", ], }, ) class TestBlockDefinitionLookup(TestCase): def test_simple_lookup(self): lookup = BlockDefinitionLookup( { 0: ("wagtail.blocks.CharBlock", [], {"required": True}), 1: ("wagtail.blocks.RichTextBlock", [], {}), } ) char_block = lookup.get_block(0) char_block.set_name("title") self.assertIsInstance(char_block, blocks.CharBlock) self.assertTrue(char_block.required) rich_text_block = lookup.get_block(1) self.assertIsInstance(rich_text_block, blocks.RichTextBlock) # A subsequent call to get_block with the same index should return a new instance; # this ensures that state changes such as set_name are independent of other blocks char_block_2 = lookup.get_block(0) char_block_2.set_name("subtitle") self.assertIsInstance(char_block, blocks.CharBlock) self.assertTrue(char_block.required) self.assertIsNot(char_block, char_block_2) self.assertEqual(char_block.name, "title") self.assertEqual(char_block_2.name, "subtitle") def test_structblock_lookup(self): lookup = BlockDefinitionLookup( { 0: ("wagtail.blocks.CharBlock", [], {"required": True}), 1: ("wagtail.blocks.RichTextBlock", [], {}), 2: ( "wagtail.blocks.StructBlock", [ [ ("title", 0), ("description", 1), ], ], {}, ), } ) struct_block = lookup.get_block(2) self.assertIsInstance(struct_block, blocks.StructBlock) title_block = struct_block.child_blocks["title"] self.assertIsInstance(title_block, blocks.CharBlock) self.assertTrue(title_block.required) description_block = struct_block.child_blocks["description"] self.assertIsInstance(description_block, blocks.RichTextBlock) def test_streamblock_lookup(self): lookup = BlockDefinitionLookup( { 0: ("wagtail.blocks.CharBlock", [], {"required": True}), 1: ("wagtail.blocks.RichTextBlock", [], {}), 2: ( "wagtail.blocks.StreamBlock", [ [ ("heading", 0), ("paragraph", 1), ], ], {}, ), } ) stream_block = lookup.get_block(2) self.assertIsInstance(stream_block, blocks.StreamBlock) title_block = stream_block.child_blocks["heading"] self.assertIsInstance(title_block, blocks.CharBlock) self.assertTrue(title_block.required) description_block = stream_block.child_blocks["paragraph"] self.assertIsInstance(description_block, blocks.RichTextBlock) def test_listblock_lookup(self): lookup = BlockDefinitionLookup( { 0: ("wagtail.blocks.CharBlock", [], {"required": True}), 1: ("wagtail.blocks.ListBlock", [0], {}), } ) list_block = lookup.get_block(1) self.assertIsInstance(list_block, blocks.ListBlock) list_item_block = list_block.child_block self.assertIsInstance(list_item_block, blocks.CharBlock) self.assertTrue(list_item_block.required) # Passing a class as the child block is still valid; this is not converted # to a reference lookup = BlockDefinitionLookup( { 0: ("wagtail.blocks.ListBlock", [blocks.CharBlock], {}), } ) list_block = lookup.get_block(0) self.assertIsInstance(list_block, blocks.ListBlock) list_item_block = list_block.child_block self.assertIsInstance(list_item_block, blocks.CharBlock)