mirror of
https://github.com/Hopiu/wagtail.git
synced 2026-05-12 01:03:11 +00:00
This allows template authors to write `{{ page.stream_field }}` in
Jinja2 templates without having to jump through HTML escaping loops.
314 lines
12 KiB
Python
314 lines
12 KiB
Python
from __future__ import absolute_import, unicode_literals
|
|
|
|
import collections
|
|
|
|
from django import forms
|
|
from django.core.exceptions import ValidationError
|
|
from django.forms.utils import ErrorList
|
|
from django.template.loader import render_to_string
|
|
from django.utils.encoding import python_2_unicode_compatible, force_text
|
|
from django.utils.html import format_html_join
|
|
from django.utils.safestring import mark_safe
|
|
|
|
# Must be imported from Django so we get the new implementation of with_metaclass
|
|
from django.utils import six
|
|
|
|
from wagtail.wagtailcore.utils import escape_script
|
|
|
|
from .base import Block, DeclarativeSubBlocksMetaclass, BoundBlock
|
|
from .utils import indent, js_dict
|
|
|
|
|
|
__all__ = ['BaseStreamBlock', 'StreamBlock', 'StreamValue']
|
|
|
|
|
|
class BaseStreamBlock(Block):
|
|
class Meta:
|
|
default = []
|
|
|
|
def __init__(self, local_blocks=None, **kwargs):
|
|
self._constructor_kwargs = kwargs
|
|
|
|
super(BaseStreamBlock, self).__init__(**kwargs)
|
|
|
|
self.child_blocks = self.base_blocks.copy() # create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
|
|
if local_blocks:
|
|
for name, block in local_blocks:
|
|
block.set_name(name)
|
|
self.child_blocks[name] = block
|
|
|
|
self.dependencies = self.child_blocks.values()
|
|
|
|
def get_default(self):
|
|
"""
|
|
Default values set on a StreamBlock should be a list of (type_name, value) tuples -
|
|
we can't use StreamValue directly, because that would require a reference back to
|
|
the StreamBlock that hasn't been built yet.
|
|
|
|
For consistency, then, we need to convert it to a StreamValue here for StreamBlock
|
|
to work with.
|
|
"""
|
|
return StreamValue(self, self.meta.default)
|
|
|
|
def render_list_member(self, block_type_name, value, prefix, index, errors=None):
|
|
"""
|
|
Render the HTML for a single list item. This consists of an <li> wrapper, hidden fields
|
|
to manage ID/deleted state/type, delete/reorder buttons, and the child block's own HTML.
|
|
"""
|
|
child_block = self.child_blocks[block_type_name]
|
|
child = child_block.bind(value, prefix="%s-value" % prefix, errors=errors)
|
|
return render_to_string('wagtailadmin/block_forms/stream_member.html', {
|
|
'child_blocks': self.child_blocks.values(),
|
|
'block_type_name': block_type_name,
|
|
'prefix': prefix,
|
|
'child': child,
|
|
'index': index,
|
|
})
|
|
|
|
def html_declarations(self):
|
|
return format_html_join(
|
|
'\n', '<script type="text/template" id="{0}-newmember-{1}">{2}</script>',
|
|
[
|
|
(
|
|
self.definition_prefix,
|
|
name,
|
|
mark_safe(escape_script(self.render_list_member(name, child_block.get_default(), '__PREFIX__', '')))
|
|
)
|
|
for name, child_block in self.child_blocks.items()
|
|
]
|
|
)
|
|
|
|
@property
|
|
def media(self):
|
|
return forms.Media(js=['wagtailadmin/js/blocks/sequence.js', 'wagtailadmin/js/blocks/stream.js'])
|
|
|
|
def js_initializer(self):
|
|
# compile a list of info dictionaries, one for each available block type
|
|
child_blocks = []
|
|
for name, child_block in self.child_blocks.items():
|
|
# each info dictionary specifies at least a block name
|
|
child_block_info = {'name': "'%s'" % name}
|
|
|
|
# if the child defines a JS initializer function, include that in the info dict
|
|
# along with the param that needs to be passed to it for initializing an empty/default block
|
|
# of that type
|
|
child_js_initializer = child_block.js_initializer()
|
|
if child_js_initializer:
|
|
child_block_info['initializer'] = child_js_initializer
|
|
|
|
child_blocks.append(indent(js_dict(child_block_info)))
|
|
|
|
opts = {
|
|
'definitionPrefix': "'%s'" % self.definition_prefix,
|
|
'childBlocks': '[\n%s\n]' % ',\n'.join(child_blocks),
|
|
}
|
|
|
|
return "StreamBlock(%s)" % js_dict(opts)
|
|
|
|
def render_form(self, value, prefix='', errors=None):
|
|
if errors:
|
|
if len(errors) > 1:
|
|
# We rely on ListBlock.clean throwing a single ValidationError with a specially crafted
|
|
# 'params' attribute that we can pull apart and distribute to the child blocks
|
|
raise TypeError('ListBlock.render_form unexpectedly received multiple errors')
|
|
error_list = errors.as_data()[0].params
|
|
else:
|
|
error_list = None
|
|
|
|
# drop any child values that are an unrecognised block type
|
|
valid_children = [child for child in value if child.block_type in self.child_blocks]
|
|
|
|
list_members_html = [
|
|
self.render_list_member(child.block_type, child.value, "%s-%d" % (prefix, i), i,
|
|
errors=error_list[i] if error_list else None)
|
|
for (i, child) in enumerate(valid_children)
|
|
]
|
|
|
|
return render_to_string('wagtailadmin/block_forms/stream.html', {
|
|
'prefix': prefix,
|
|
'list_members_html': list_members_html,
|
|
'child_blocks': self.child_blocks.values(),
|
|
'header_menu_prefix': '%s-before' % prefix,
|
|
})
|
|
|
|
def value_from_datadict(self, data, files, prefix):
|
|
count = int(data['%s-count' % prefix])
|
|
values_with_indexes = []
|
|
for i in range(0, count):
|
|
if data['%s-%d-deleted' % (prefix, i)]:
|
|
continue
|
|
block_type_name = data['%s-%d-type' % (prefix, i)]
|
|
try:
|
|
child_block = self.child_blocks[block_type_name]
|
|
except KeyError:
|
|
continue
|
|
|
|
values_with_indexes.append(
|
|
(
|
|
int(data['%s-%d-order' % (prefix, i)]),
|
|
block_type_name,
|
|
child_block.value_from_datadict(data, files, '%s-%d-value' % (prefix, i)),
|
|
)
|
|
)
|
|
|
|
values_with_indexes.sort()
|
|
return StreamValue(self, [
|
|
(child_block_type_name, value)
|
|
for (index, child_block_type_name, value) in values_with_indexes
|
|
])
|
|
|
|
def clean(self, value):
|
|
cleaned_data = []
|
|
errors = []
|
|
for child in value: # child is a BoundBlock instance
|
|
try:
|
|
cleaned_data.append(
|
|
(child.block.name, child.block.clean(child.value))
|
|
)
|
|
except ValidationError as e:
|
|
errors.append(ErrorList([e]))
|
|
else:
|
|
errors.append(None)
|
|
|
|
if any(errors):
|
|
# The message here is arbitrary - outputting error messages is delegated to the child blocks,
|
|
# which only involves the 'params' list
|
|
raise ValidationError('Validation error in StreamBlock', params=errors)
|
|
|
|
return StreamValue(self, cleaned_data)
|
|
|
|
def to_python(self, value):
|
|
# the incoming JSONish representation is a list of dicts, each with a 'type' and 'value' field.
|
|
# This is passed to StreamValue to be expanded lazily - but first we reject any unrecognised
|
|
# block types from the list
|
|
return StreamValue(self, [
|
|
child_data for child_data in value
|
|
if child_data['type'] in self.child_blocks
|
|
], is_lazy=True)
|
|
|
|
def get_prep_value(self, value):
|
|
if value is None:
|
|
# treat None as identical to an empty stream
|
|
return []
|
|
|
|
return [
|
|
{'type': child.block.name, 'value': child.block.get_prep_value(child.value)}
|
|
for child in value # child is a BoundBlock instance
|
|
]
|
|
|
|
def render_basic(self, value):
|
|
return format_html_join('\n', '<div class="block-{1}">{0}</div>',
|
|
[(force_text(child), child.block_type) for child in value]
|
|
)
|
|
|
|
def get_searchable_content(self, value):
|
|
content = []
|
|
|
|
for child in value:
|
|
content.extend(child.block.get_searchable_content(child.value))
|
|
|
|
return content
|
|
|
|
def deconstruct(self):
|
|
"""
|
|
Always deconstruct StreamBlock instances as if they were plain StreamBlocks with all of the
|
|
field definitions passed to the constructor - even if in reality this is a subclass of StreamBlock
|
|
with the fields defined declaratively, or some combination of the two.
|
|
|
|
This ensures that the field definitions get frozen into migrations, rather than leaving a reference
|
|
to a custom subclass in the user's models.py that may or may not stick around.
|
|
"""
|
|
path = 'wagtail.wagtailcore.blocks.StreamBlock'
|
|
args = [self.child_blocks.items()]
|
|
kwargs = self._constructor_kwargs
|
|
return (path, args, kwargs)
|
|
|
|
def check(self, **kwargs):
|
|
errors = super(BaseStreamBlock, self).check(**kwargs)
|
|
for name, child_block in self.child_blocks.items():
|
|
errors.extend(child_block.check(**kwargs))
|
|
errors.extend(child_block._check_name(**kwargs))
|
|
|
|
return errors
|
|
|
|
|
|
class StreamBlock(six.with_metaclass(DeclarativeSubBlocksMetaclass, BaseStreamBlock)):
|
|
pass
|
|
|
|
|
|
@python_2_unicode_compatible # provide equivalent __unicode__ and __str__ methods on Py2
|
|
class StreamValue(collections.Sequence):
|
|
"""
|
|
Custom type used to represent the value of a StreamBlock; behaves as a sequence of BoundBlocks
|
|
(which keep track of block types in a way that the values alone wouldn't).
|
|
"""
|
|
|
|
@python_2_unicode_compatible
|
|
class StreamChild(BoundBlock):
|
|
"""Provides some extensions to BoundBlock to make it more natural to work with on front-end templates"""
|
|
def __str__(self):
|
|
"""Render the value according to the block's native rendering"""
|
|
return self.block.render(self.value)
|
|
|
|
@property
|
|
def block_type(self):
|
|
"""
|
|
Syntactic sugar so that we can say child.block_type instead of child.block.name.
|
|
(This doesn't belong on BoundBlock itself because the idea of block.name denoting
|
|
the child's "type" ('heading', 'paragraph' etc) is unique to StreamBlock, and in the
|
|
wider context people are liable to confuse it with the block class (CharBlock etc).
|
|
"""
|
|
return self.block.name
|
|
|
|
def __init__(self, stream_block, stream_data, is_lazy=False, raw_text=None):
|
|
"""
|
|
Construct a StreamValue linked to the given StreamBlock,
|
|
with child values given in stream_data.
|
|
|
|
Passing is_lazy=True means that stream_data is raw JSONish data as stored
|
|
in the database, and needs to be converted to native values
|
|
(using block.to_python()) when accessed. In this mode, stream_data is a
|
|
list of dicts, each containing 'type' and 'value' keys.
|
|
|
|
Passing is_lazy=False means that stream_data consists of immediately usable
|
|
native values. In this mode, stream_data is a list of (type_name, value)
|
|
tuples.
|
|
|
|
raw_text exists solely as a way of representing StreamField content that is
|
|
not valid JSON; this may legitimately occur if an existing text field is
|
|
migrated to a StreamField. In this situation we return a blank StreamValue
|
|
with the raw text accessible under the `raw_text` attribute, so that migration
|
|
code can be rewritten to convert it as desired.
|
|
"""
|
|
self.is_lazy = is_lazy
|
|
self.stream_block = stream_block # the StreamBlock object that handles this value
|
|
self.stream_data = stream_data # a list of (type_name, value) tuples
|
|
self._bound_blocks = {} # populated lazily from stream_data as we access items through __getitem__
|
|
self.raw_text = raw_text
|
|
|
|
def __getitem__(self, i):
|
|
if i not in self._bound_blocks:
|
|
if self.is_lazy:
|
|
raw_value = self.stream_data[i]
|
|
type_name = raw_value['type']
|
|
child_block = self.stream_block.child_blocks[type_name]
|
|
value = child_block.to_python(raw_value['value'])
|
|
else:
|
|
type_name, value = self.stream_data[i]
|
|
child_block = self.stream_block.child_blocks[type_name]
|
|
|
|
self._bound_blocks[i] = StreamValue.StreamChild(child_block, value)
|
|
|
|
return self._bound_blocks[i]
|
|
|
|
def __len__(self):
|
|
return len(self.stream_data)
|
|
|
|
def __repr__(self):
|
|
return repr(list(self))
|
|
|
|
def __html__(self):
|
|
return self.__str__()
|
|
|
|
def __str__(self):
|
|
return self.stream_block.render(self)
|