diff --git a/wagtail/wagtailadmin/blocks.py b/wagtail/wagtailadmin/blocks.py index 887cd47f8..4cd3dba8d 100644 --- a/wagtail/wagtailadmin/blocks.py +++ b/wagtail/wagtailadmin/blocks.py @@ -1,1011 +1,5 @@ -from __future__ import unicode_literals -# this ensures that any render / __str__ methods returning HTML via calls to mark_safe / format_html -# return a SafeText, not SafeBytes; necessary so that it doesn't get re-encoded when the template engine -# calls force_text, which would cause it to lose its 'safe' flag +import warnings -import re -import collections +warnings.warn("wagtail.wagtailadmin.blocks has moved to wagtail.wagtailcore.blocks", UserWarning, stacklevel=2) -from django.core.exceptions import ValidationError, ImproperlyConfigured -from django.utils.html import format_html, format_html_join -from django.utils.safestring import mark_safe -from django.utils.text import capfirst -from django.utils.encoding import force_text, python_2_unicode_compatible -from django.utils.deconstruct import deconstructible -from django.utils.functional import cached_property -from django.template.loader import render_to_string -from django import forms -from django.forms.utils import ErrorList - -import six - -from wagtail.wagtailcore.utils import escape_script -from wagtail.wagtailcore.rich_text import expand_db_html - -# helpers for Javascript expression formatting - -def indent(string, depth=1): - """indent all non-empty lines of string by 'depth' 4-character tabs""" - return re.sub(r'(^|\n)([^\n]+)', '\g<1>' + (' ' * depth) + '\g<2>', string) - -def js_dict(d): - """ - Return a Javascript expression string for the dict 'd'. - Keys are assumed to be strings consisting only of JS-safe characters, and will be quoted but not escaped; - values are assumed to be valid Javascript expressions and will be neither escaped nor quoted (but will be - wrapped in parentheses, in case some awkward git decides to use the comma operator...) - """ - dict_items = [ - indent("'%s': (%s)" % (k, v)) - for (k, v) in d.items() - ] - return "{\n%s\n}" % ',\n'.join(dict_items) - -# ========================================= -# Top-level superclasses and helper objects -# ========================================= - - -class BaseBlock(type): - def __new__(mcs, name, bases, attrs): - meta_class = attrs.pop('Meta', None) - - cls = super(BaseBlock, mcs).__new__(mcs, name, bases, attrs) - - base_meta_class = getattr(cls, '_meta_class', None) - bases = tuple(cls for cls in [meta_class, base_meta_class] if cls) or () - cls._meta_class = type(str(name + 'Meta'), bases + (object, ), {}) - - return cls - - -@deconstructible -class Block(six.with_metaclass(BaseBlock, object)): - name = '' - creation_counter = 0 - - class Meta: - label = None - icon = "placeholder" - classname = None - - """ - Setting a 'dependencies' list serves as a shortcut for the common case where a complex block type - (such as struct, list or stream) relies on one or more inner block objects, and needs to ensure that - the responses from the 'media' and 'html_declarations' include the relevant declarations for those inner - blocks, as well as its own. Specifying these inner block objects in a 'dependencies' list means that - the base 'media' and 'html_declarations' methods will return those declarations; the outer block type can - then add its own declarations to the list by overriding those methods and using super(). - """ - dependencies = [] - - def all_blocks(self): - """ - Return a set consisting of self and all block objects that are direct or indirect dependencies - of this block - """ - result = set([self]) - for dep in self.dependencies: - result |= dep.all_blocks() - return result - - def all_media(self): - media = forms.Media() - for block in self.all_blocks(): - media += block.media - return media - - def all_html_declarations(self): - declarations = filter(bool, [block.html_declarations() for block in self.all_blocks()]) - return mark_safe('\n'.join(declarations)) - - def __init__(self, **kwargs): - self.meta = self._meta_class() - - for attr, value in kwargs.items(): - setattr(self.meta, attr, value) - - # Increase the creation counter, and save our local copy. - self.creation_counter = Block.creation_counter - Block.creation_counter += 1 - self.definition_prefix = 'blockdef-%d' % self.creation_counter - - self.label = self.meta.label or '' - - def set_name(self, name): - self.name = name - if not self.meta.label: - self.label = capfirst(name.replace('_', ' ')) - - @property - def media(self): - return forms.Media() - - def html_declarations(self): - """ - Return an HTML fragment to be rendered on the form page once per block definition - - as opposed to once per occurrence of the block. For example, the block definition - ListBlock(label="Shopping list", CharBlock(label="Product")) - needs to output a block containing the HTML for - a 'product' text input, to that these can be dynamically added to the list. This - template block must only occur once in the page, even if there are multiple 'shopping list' - blocks on the page. - - Any element IDs used in this HTML fragment must begin with definition_prefix. - (More precisely, they must either be definition_prefix itself, or begin with definition_prefix - followed by a '-' character) - """ - return '' - - def js_initializer(self): - """ - Returns a Javascript expression string, or None if this block does not require any - Javascript behaviour. This expression evaluates to an initializer function, a function that - takes the ID prefix and applies JS behaviour to the block instance with that value and prefix. - - The parent block of this block (or the top-level page code) must ensure that this - expression is not evaluated more than once. (The resulting initializer function can and will be - called as many times as there are instances of this block, though.) - """ - return None - - def render_form(self, value, prefix='', errors=None): - """ - Render the HTML for this block with 'value' as its content. - """ - raise NotImplementedError('%s.render_form' % self.__class__) - - def value_from_datadict(self, data, files, prefix): - raise NotImplementedError('%s.value_from_datadict' % self.__class__) - - def bind(self, value, prefix=None, errors=None): - """ - Return a BoundBlock which represents the association of this block definition with a value - and a prefix (and optionally, a ValidationError to be rendered). - BoundBlock primarily exists as a convenience to allow rendering within templates: - bound_block.render() rather than blockdef.render(value, prefix) which can't be called from - within a template. - """ - return BoundBlock(self, value, prefix=prefix, errors=errors) - - def prototype_block(self): - """ - Return a BoundBlock that can be used as a basis for new empty block instances to be added on the fly - (new list items, for example). This will have a prefix of '__PREFIX__' (to be dynamically replaced with - a real prefix when it's inserted into the page) and a value equal to the block's default value. - """ - return self.bind(self.meta.default, '__PREFIX__') - - def clean(self, value): - """ - Validate value and return a cleaned version of it, or throw a ValidationError if validation fails. - The thrown ValidationError instance will subsequently be passed to render() to display the - error message; nested blocks therefore need to wrap child validations like this: - https://docs.djangoproject.com/en/dev/ref/forms/validation/#raising-multiple-errors - - NB The ValidationError must have an error_list property (which can be achieved by passing a - list or an individual error message to its constructor), NOT an error_dict - - Django has problems nesting ValidationErrors with error_dicts. - """ - return value - - def to_python(self, value): - """ - Convert 'value' from a simple (JSON-serialisable) value to a (possibly complex) Python value to be - used in the rest of the block API and within front-end templates . In simple cases this might be - the value itself; alternatively, it might be a 'smart' version of the value which behaves mostly - like the original value but provides a native HTML rendering when inserted into a template; or it - might be something totally different (e.g. an image chooser will use the image ID as the clean - value, and turn this back into an actual image object here). - """ - return value - - def get_prep_value(self, value): - """ - The reverse of to_python; convert the python value into JSON-serialisable form. - """ - return value - - def render(self, value): - """ - Return a text rendering of 'value', suitable for display on templates. By default, this will - use a template if a 'template' property is specified on the block, and fall back on render_basic - otherwise. - """ - template = getattr(self.meta, 'template', None) - if template: - return render_to_string(template, {'self': value}) - else: - return self.render_basic(value) - - def render_basic(self, value): - """ - Return a text rendering of 'value', suitable for display on templates. render() will fall back on - this if the block does not define a 'template' property. - """ - return force_text(value) - - def __eq__(self, other): - """ - The deep_deconstruct method in django.db.migrations.autodetector.MigrationAutodetector does not - recurse into arbitrary lists and dicts. As a result, when it is passed a field such as: - StreamField([ - ('heading', CharBlock()), - ]) - the CharBlock object will be left in its constructed form. This causes problems when - MigrationAutodetector compares two separate instances of the StreamField from different project - states: since the CharBlocks are different objects, it will report a change where there isn't one. - - To prevent this, we implement the equality operator on Block instances such that the two CharBlocks - are reported as equal. Since block objects are intended to be immutable with the exception of - set_name(), it is sufficient to compare the 'name' property and the constructor args/kwargs of the - two block objects. The 'deconstruct' method provides a convenient way to access the latter. - """ - - if not isinstance(other, Block): - # if the other object isn't a block at all, it clearly isn't equal. - return False - - # Note that we do not require the two blocks to be of the exact same class. This is because - # we may wish the following blocks to be considered equal: - # - # class FooBlock(StructBlock): - # first_name = CharBlock() - # surname = CharBlock() - # - # class BarBlock(StructBlock): - # first_name = CharBlock() - # surname = CharBlock() - # - # FooBlock() == BarBlock() == StructBlock([('first_name', CharBlock()), ('surname': CharBlock())]) - # - # For this to work, StructBlock will need to ensure that 'deconstruct' returns the same signature - # in all of these cases, including reporting StructBlock as the path: - # - # FooBlock().deconstruct() == ( - # 'wagtail.wagtailadmin.blocks.StructBlock', - # [('first_name', CharBlock()), ('surname': CharBlock())], - # {} - # ) - # - # This has the bonus side effect that the StructBlock field definition gets frozen into - # the migration, rather than leaving the migration vulnerable to future changes to FooBlock / BarBlock - # in models.py. - - return (self.name == other.name) and (self.deconstruct() == other.deconstruct()) - - def __ne__(self, other): - return not self.__eq__(other) - - # Making block instances hashable in a way that's consistent with __eq__ is non-trivial, because - # self.deconstruct() is liable to contain unhashable data (e.g. lists and dicts). So let's set - # Block to be explicitly unhashable - Python 3 will do this automatically when defining __eq__, - # but Python 2 won't, and we'd like the behaviour to be consistent on both. - __hash__ = None - - -class BoundBlock(object): - def __init__(self, block, value, prefix=None, errors=None): - self.block = block - self.value = value - self.prefix = prefix - self.errors = errors - - def render_form(self): - return self.block.render_form(self.value, self.prefix, errors=self.errors) - - def render(self): - return self.block.render(self.value) - - -# =========== -# Field block -# =========== - -# FIXME: form field instances are not deconstructible for migrations. Need some other way to refer to -# them in the initialiser, in the case that FieldBlock appears inline within a StreamField definition. -# (Referring to them by class would probably work; it's unlikely that any parameter passed to them -# would affect anything you're doing in migrations) - -class FieldBlock(Block): - class Meta: - default = None - - def render_form(self, value, prefix='', errors=None): - widget = self.field.widget - - if self.label: - label_html = format_html( - """ """, - label_id=widget.id_for_label(prefix), label=self.label - ) - else: - label_html = '' - - widget_attrs = {'id': prefix, 'placeholder': self.label} - - if hasattr(widget, 'render_with_errors'): - widget_html = widget.render_with_errors(prefix, value, attrs=widget_attrs, errors=errors) - widget_has_rendered_errors = True - else: - widget_html = widget.render(prefix, value, attrs=widget_attrs) - widget_has_rendered_errors = False - - return render_to_string('wagtailadmin/block_forms/field.html', { - 'name': self.name, - 'label': self.label, - 'classes': self.meta.classname, - 'widget': widget_html, - 'label_tag': label_html, - 'field': self.field, - 'errors': errors if (not widget_has_rendered_errors) else None - }) - - def value_from_datadict(self, data, files, prefix): - return self.to_python(self.field.widget.value_from_datadict(data, files, prefix)) - - def clean(self, value): - return self.field.clean(value) - -class CharBlock(FieldBlock): - def __init__(self, required=True, help_text=None, max_length=None, min_length=None, **kwargs): - # TODO: decide what to do about 'label' and 'initial' parameters to the form field - self.field = forms.CharField(required=required, help_text=help_text, max_length=max_length, min_length=min_length) - super(CharBlock, self).__init__(**kwargs) - -class URLBlock(FieldBlock): - def __init__(self, required=True, help_text=None, max_length=None, min_length=None, **kwargs): - self.field = forms.URLField(required=required, help_text=help_text, max_length=max_length, min_length=min_length) - super(URLBlock, self).__init__(**kwargs) - -class RichTextBlock(FieldBlock): - @cached_property - def field(self): - from wagtail.wagtailcore.fields import RichTextArea - return forms.CharField(widget=RichTextArea) - - def render_basic(self, value): - return mark_safe('
' + expand_db_html(value) + '
') - -class RawHTMLBlock(FieldBlock): - def __init__(self, required=True, help_text=None, max_length=None, min_length=None, **kwargs): - self.field = forms.CharField( - required=required, help_text=help_text, max_length=max_length, min_length=min_length, - widget = forms.Textarea) - super(RawHTMLBlock, self).__init__(**kwargs) - - def render_basic(self, value): - return mark_safe(value) # if it isn't safe, that's the site admin's problem for allowing raw HTML blocks in the first place... - - class Meta: - icon = 'code' - - -class ChooserBlock(FieldBlock): - def __init__(self, required=True, **kwargs): - self.required=required - super(ChooserBlock, self).__init__(**kwargs) - - """Abstract superclass for fields that implement a chooser interface (page, image, snippet etc)""" - @cached_property - def field(self): - return forms.ModelChoiceField(queryset=self.target_model.objects.all(), widget=self.widget, required=self.required) - - def to_python(self, value): - if value is None or isinstance(value, self.target_model): - return value - else: - try: - return self.target_model.objects.get(pk=value) - except self.target_model.DoesNotExist: - return None - - def get_prep_value(self, value): - if isinstance(value, self.target_model): - return value.id - else: - return value - - def clean(self, value): - # ChooserBlock works natively with model instances as its 'value' type (because that's what you - # want to work with when doing front-end templating), but ModelChoiceField.clean expects an ID - # as the input value (and returns a model instance as the result). We don't want to bypass - # ModelChoiceField.clean entirely (it might be doing relevant validation, such as checking page - # type) so we convert our instance back to an ID here. It means we have a wasted round-trip to - # the database when ModelChoiceField.clean promptly does its own lookup, but there's no easy way - # around that... - if isinstance(value, self.target_model): - value = value.pk - return super(ChooserBlock, self).clean(value) - -class PageChooserBlock(ChooserBlock): - @cached_property - def target_model(self): - from wagtail.wagtailcore.models import Page # TODO: allow limiting to specific page types - return Page - - @cached_property - def widget(self): - from wagtail.wagtailadmin.widgets import AdminPageChooser - return AdminPageChooser - - def render_basic(self, value): - if value: - return format_html('{1}', value.url, value.title) - else: - return '' - - -# =========== -# StructBlock -# =========== - -class BaseStructBlock(Block): - class Meta: - default = {} - template = "wagtailadmin/blocks/struct.html" - - def __init__(self, local_blocks=None, **kwargs): - super(BaseStructBlock, 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.child_js_initializers = {} - for name, block in self.child_blocks.items(): - js_initializer = block.js_initializer() - if js_initializer is not None: - self.child_js_initializers[name] = js_initializer - - self.dependencies = self.child_blocks.values() - - def js_initializer(self): - # skip JS setup entirely if no children have js_initializers - if not self.child_js_initializers: - return None - - return "StructBlock(%s)" % js_dict(self.child_js_initializers) - - @property - def media(self): - return forms.Media(js=['wagtailadmin/js/blocks/struct.js']) - - def render_form(self, value, prefix='', errors=None): - if errors: - if len(errors) > 1: - # We rely on StructBlock.clean throwing a single ValidationError with a specially crafted - # 'params' attribute that we can pull apart and distribute to the child blocks - raise TypeError('StructBlock.render_form unexpectedly received multiple errors') - error_dict = errors.as_data()[0].params - else: - error_dict = {} - - child_renderings = [ - block.render_form(value.get(name, block.meta.default), prefix="%s-%s" % (prefix, name), - errors=error_dict.get(name)) - for name, block in self.child_blocks.items() - ] - - list_items = format_html_join('\n', "
  • {0}
  • ", [ - [child_rendering] - for child_rendering in child_renderings - ]) - - - # Can these be rendered with a template? - if self.label: - return format_html('
    ', self.label, list_items) - else: - return format_html('
    ', list_items) - - def value_from_datadict(self, data, files, prefix): - return dict([ - (name, block.value_from_datadict(data, files, '%s-%s' % (prefix, name))) - for name, block in self.child_blocks.items() - ]) - - def clean(self, value): - result = {} - errors = {} - for name, val in value.items(): - try: - result[name] = self.child_blocks[name].clean(val) - except ValidationError as e: - errors[name] = ErrorList([e]) - - if errors: - # The message here is arbitrary - StructBlock.render_form will suppress it - # and delegate the errors contained in the 'params' dict to the child blocks instead - raise ValidationError('Validation error in StructBlock', params=errors) - - return result - - def to_python(self, value): - # recursively call to_python on children and return as a StructValue - return StructValue(self, [ - ( - name, - child_block.to_python(value.get(name, child_block.meta.default)) - ) - for name, child_block in self.child_blocks.items() - ]) - - def get_prep_value(self, value): - # recursively call get_prep_value on children and return as a plain dict - return dict([ - (name, self.child_blocks[name].get_prep_value(val)) - for name, val in value.items() - ]) - -@python_2_unicode_compatible # provide equivalent __unicode__ and __str__ methods on Py2 -class StructValue(collections.OrderedDict): - def __init__(self, block, *args): - super(StructValue, self).__init__(*args) - self.block = block - - def __str__(self): - return self.block.render(self) - - @cached_property - def bound_blocks(self): - return collections.OrderedDict([ - (name, block.bind(self.get(name))) - for name, block in self.block.child_blocks.items() - ]) - - -class DeclarativeSubBlocksMetaclass(BaseBlock): - """ - Metaclass that collects sub-blocks declared on the base classes. - (cheerfully stolen from https://github.com/django/django/blob/master/django/forms/forms.py) - """ - def __new__(mcs, name, bases, attrs): - # Collect sub-blocks from current class. - current_blocks = [] - for key, value in list(attrs.items()): - if isinstance(value, Block): - current_blocks.append((key, value)) - value.set_name(key) - attrs.pop(key) - current_blocks.sort(key=lambda x: x[1].creation_counter) - attrs['declared_blocks'] = collections.OrderedDict(current_blocks) - - new_class = (super(DeclarativeSubBlocksMetaclass, mcs) - .__new__(mcs, name, bases, attrs)) - - # Walk through the MRO. - declared_blocks = collections.OrderedDict() - for base in reversed(new_class.__mro__): - # Collect sub-blocks from base class. - if hasattr(base, 'declared_blocks'): - declared_blocks.update(base.declared_blocks) - - # Field shadowing. - for attr, value in base.__dict__.items(): - if value is None and attr in declared_blocks: - declared_blocks.pop(attr) - - new_class.base_blocks = declared_blocks - new_class.declared_blocks = declared_blocks - - return new_class - -class StructBlock(six.with_metaclass(DeclarativeSubBlocksMetaclass, BaseStructBlock)): - pass - - -# ========= -# ListBlock -# ========= - -class ListBlock(Block): - class Meta: - # Default to a list consisting of one empty child item (using None to trigger the child's empty / default rendering) - default = [None] - - def __init__(self, child_block, **kwargs): - super(ListBlock, self).__init__(**kwargs) - - if isinstance(child_block, type): - # child_block was passed as a class, so convert it to a block instance - self.child_block = child_block() - else: - self.child_block = child_block - - self.dependencies = [self.child_block] - self.child_js_initializer = self.child_block.js_initializer() - - @property - def media(self): - return forms.Media(js=['wagtailadmin/js/blocks/sequence.js', 'wagtailadmin/js/blocks/list.js']) - - def render_list_member(self, value, prefix, index, errors=None): - """ - Render the HTML for a single list item in the form. This consists of an
  • wrapper, hidden fields - to manage ID/deleted state, delete/reorder buttons, and the child block's own form HTML. - """ - child = self.child_block.bind(value, prefix="%s-value" % prefix, errors=errors) - return render_to_string('wagtailadmin/block_forms/list_member.html', { - 'prefix': prefix, - 'child': child, - 'index': index, - }) - - def html_declarations(self): - # generate the HTML to be used when adding a new item to the list; - # this is the output of render_list_member as rendered with the prefix '__PREFIX__' - # (to be replaced dynamically when adding the new item) and the child block's default value - # as its value. - list_member_html = self.render_list_member(self.child_block.meta.default, '__PREFIX__', '') - - return format_html( - '', - self.definition_prefix, mark_safe(escape_script(list_member_html)) - ) - - def js_initializer(self): - opts = {'definitionPrefix': "'%s'" % self.definition_prefix} - - if self.child_js_initializer: - opts['childInitializer'] = self.child_js_initializer - - return "ListBlock(%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 - - list_members_html = [ - self.render_list_member(child_val, "%s-%d" % (prefix, i), i, - errors=error_list[i] if error_list else None) - for (i, child_val) in enumerate(value) - ] - - return render_to_string('wagtailadmin/block_forms/list.html', { - 'label': self.label, - 'prefix': prefix, - 'list_members_html': list_members_html, - }) - - 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 - values_with_indexes.append( - ( - data['%s-%d-order' % (prefix, i)], - self.child_block.value_from_datadict(data, files, '%s-%d-value' % (prefix, i)) - ) - ) - - values_with_indexes.sort() - return [v for (i, v) in values_with_indexes] - - def clean(self, value): - result = [] - errors = [] - for child_val in value: - try: - result.append(self.child_block.clean(child_val)) - 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 ListBlock', params=errors) - - return result - - def to_python(self, value): - # recursively call to_python on children and return as a list - return [ - self.child_block.to_python(item) - for item in value - ] - - def get_prep_value(self, value): - # recursively call get_prep_value on children and return as a list - return [ - self.child_block.get_prep_value(item) - for item in value - ] - - def render_basic(self, value): - children = format_html_join('\n', '
  • {0}
  • ', - [(self.child_block.render(child_value),) for child_value in value] - ) - return format_html("", children) - - -# =========== -# StreamBlock -# =========== - -class BaseStreamBlock(Block): - # TODO: decide what it means to pass a 'default' arg to StreamBlock's constructor. Logically we want it to be - # of type StreamValue, but we can't construct one of those because it needs a reference back to the StreamBlock - # that we haven't constructed yet... - class Meta: - @property - def default(self): - return StreamValue(self, []) - - def __init__(self, local_blocks=None, **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 render_list_member(self, block_type_name, value, prefix, index, errors=None): - """ - Render the HTML for a single list item. This consists of an
  • 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', '', - [ - ( - self.definition_prefix, - name, - mark_safe(escape_script(self.render_list_member(name, child_block.meta.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', { - 'label': self.label, - '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, [ - (block_type_name, value) - for (index, 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. - # Convert this to a StreamValue backed by a list of (type, value) tuples - return StreamValue(self, [ - (child_data['type'], self.child_blocks[child_data['type']].to_python(child_data['value'])) - for child_data in value - if child_data['type'] in self.child_blocks - ]) - - def get_prep_value(self, value): - if value is None: - return None - - 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', '
    {0}
    ', - [(child, child.block_type) for child in value] - ) - - -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): - 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__ - - def __getitem__(self, i): - if i not in self._bound_blocks: - 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 __str__(self): - return self.stream_block.render(self) - - -# ======================== -# django.forms integration -# ======================== - -class BlockWidget(forms.Widget): - """Wraps a block object as a widget so that it can be incorporated into a Django form""" - def __init__(self, block_def, attrs=None): - super(BlockWidget, self).__init__(attrs=attrs) - self.block_def = block_def - - def render_with_errors(self, name, value, attrs=None, errors=None): - bound_block = self.block_def.bind(value, prefix=name, errors=errors) - js_initializer = self.block_def.js_initializer() - if js_initializer: - js_snippet = """ - - """ % (js_initializer, name) - else: - js_snippet = '' - return mark_safe(bound_block.render_form() + js_snippet) - - def render(self, name, value, attrs=None): - return self.render_with_errors(name, value, attrs=attrs, errors=None) - - @property - def media(self): - return self.block_def.all_media() - - def value_from_datadict(self, data, files, name): - return self.block_def.value_from_datadict(data, files, name) - - -class BlockField(forms.Field): - """Wraps a block object as a form field so that it can be incorporated into a Django form""" - def __init__(self, block=None, **kwargs): - if block is None: - raise ImproperlyConfigured("BlockField was not passed a 'block' object") - self.block = block - - if 'widget' not in kwargs: - kwargs['widget'] = BlockWidget(block) - - super(BlockField, self).__init__(**kwargs) - - def clean(self, value): - return self.block.clean(value) +from wagtail.wagtailcore.blocks import * diff --git a/wagtail/wagtailcore/blocks.py b/wagtail/wagtailcore/blocks.py new file mode 100644 index 000000000..ea81e30bf --- /dev/null +++ b/wagtail/wagtailcore/blocks.py @@ -0,0 +1,1011 @@ +from __future__ import unicode_literals +# this ensures that any render / __str__ methods returning HTML via calls to mark_safe / format_html +# return a SafeText, not SafeBytes; necessary so that it doesn't get re-encoded when the template engine +# calls force_text, which would cause it to lose its 'safe' flag + +import re +import collections + +from django.core.exceptions import ValidationError, ImproperlyConfigured +from django.utils.html import format_html, format_html_join +from django.utils.safestring import mark_safe +from django.utils.text import capfirst +from django.utils.encoding import force_text, python_2_unicode_compatible +from django.utils.deconstruct import deconstructible +from django.utils.functional import cached_property +from django.template.loader import render_to_string +from django import forms +from django.forms.utils import ErrorList + +import six + +from wagtail.wagtailcore.utils import escape_script +from wagtail.wagtailcore.rich_text import expand_db_html + +# helpers for Javascript expression formatting + +def indent(string, depth=1): + """indent all non-empty lines of string by 'depth' 4-character tabs""" + return re.sub(r'(^|\n)([^\n]+)', '\g<1>' + (' ' * depth) + '\g<2>', string) + +def js_dict(d): + """ + Return a Javascript expression string for the dict 'd'. + Keys are assumed to be strings consisting only of JS-safe characters, and will be quoted but not escaped; + values are assumed to be valid Javascript expressions and will be neither escaped nor quoted (but will be + wrapped in parentheses, in case some awkward git decides to use the comma operator...) + """ + dict_items = [ + indent("'%s': (%s)" % (k, v)) + for (k, v) in d.items() + ] + return "{\n%s\n}" % ',\n'.join(dict_items) + +# ========================================= +# Top-level superclasses and helper objects +# ========================================= + + +class BaseBlock(type): + def __new__(mcs, name, bases, attrs): + meta_class = attrs.pop('Meta', None) + + cls = super(BaseBlock, mcs).__new__(mcs, name, bases, attrs) + + base_meta_class = getattr(cls, '_meta_class', None) + bases = tuple(cls for cls in [meta_class, base_meta_class] if cls) or () + cls._meta_class = type(str(name + 'Meta'), bases + (object, ), {}) + + return cls + + +@deconstructible +class Block(six.with_metaclass(BaseBlock, object)): + name = '' + creation_counter = 0 + + class Meta: + label = None + icon = "placeholder" + classname = None + + """ + Setting a 'dependencies' list serves as a shortcut for the common case where a complex block type + (such as struct, list or stream) relies on one or more inner block objects, and needs to ensure that + the responses from the 'media' and 'html_declarations' include the relevant declarations for those inner + blocks, as well as its own. Specifying these inner block objects in a 'dependencies' list means that + the base 'media' and 'html_declarations' methods will return those declarations; the outer block type can + then add its own declarations to the list by overriding those methods and using super(). + """ + dependencies = [] + + def all_blocks(self): + """ + Return a set consisting of self and all block objects that are direct or indirect dependencies + of this block + """ + result = set([self]) + for dep in self.dependencies: + result |= dep.all_blocks() + return result + + def all_media(self): + media = forms.Media() + for block in self.all_blocks(): + media += block.media + return media + + def all_html_declarations(self): + declarations = filter(bool, [block.html_declarations() for block in self.all_blocks()]) + return mark_safe('\n'.join(declarations)) + + def __init__(self, **kwargs): + self.meta = self._meta_class() + + for attr, value in kwargs.items(): + setattr(self.meta, attr, value) + + # Increase the creation counter, and save our local copy. + self.creation_counter = Block.creation_counter + Block.creation_counter += 1 + self.definition_prefix = 'blockdef-%d' % self.creation_counter + + self.label = self.meta.label or '' + + def set_name(self, name): + self.name = name + if not self.meta.label: + self.label = capfirst(name.replace('_', ' ')) + + @property + def media(self): + return forms.Media() + + def html_declarations(self): + """ + Return an HTML fragment to be rendered on the form page once per block definition - + as opposed to once per occurrence of the block. For example, the block definition + ListBlock(label="Shopping list", CharBlock(label="Product")) + needs to output a block containing the HTML for + a 'product' text input, to that these can be dynamically added to the list. This + template block must only occur once in the page, even if there are multiple 'shopping list' + blocks on the page. + + Any element IDs used in this HTML fragment must begin with definition_prefix. + (More precisely, they must either be definition_prefix itself, or begin with definition_prefix + followed by a '-' character) + """ + return '' + + def js_initializer(self): + """ + Returns a Javascript expression string, or None if this block does not require any + Javascript behaviour. This expression evaluates to an initializer function, a function that + takes the ID prefix and applies JS behaviour to the block instance with that value and prefix. + + The parent block of this block (or the top-level page code) must ensure that this + expression is not evaluated more than once. (The resulting initializer function can and will be + called as many times as there are instances of this block, though.) + """ + return None + + def render_form(self, value, prefix='', errors=None): + """ + Render the HTML for this block with 'value' as its content. + """ + raise NotImplementedError('%s.render_form' % self.__class__) + + def value_from_datadict(self, data, files, prefix): + raise NotImplementedError('%s.value_from_datadict' % self.__class__) + + def bind(self, value, prefix=None, errors=None): + """ + Return a BoundBlock which represents the association of this block definition with a value + and a prefix (and optionally, a ValidationError to be rendered). + BoundBlock primarily exists as a convenience to allow rendering within templates: + bound_block.render() rather than blockdef.render(value, prefix) which can't be called from + within a template. + """ + return BoundBlock(self, value, prefix=prefix, errors=errors) + + def prototype_block(self): + """ + Return a BoundBlock that can be used as a basis for new empty block instances to be added on the fly + (new list items, for example). This will have a prefix of '__PREFIX__' (to be dynamically replaced with + a real prefix when it's inserted into the page) and a value equal to the block's default value. + """ + return self.bind(self.meta.default, '__PREFIX__') + + def clean(self, value): + """ + Validate value and return a cleaned version of it, or throw a ValidationError if validation fails. + The thrown ValidationError instance will subsequently be passed to render() to display the + error message; nested blocks therefore need to wrap child validations like this: + https://docs.djangoproject.com/en/dev/ref/forms/validation/#raising-multiple-errors + + NB The ValidationError must have an error_list property (which can be achieved by passing a + list or an individual error message to its constructor), NOT an error_dict - + Django has problems nesting ValidationErrors with error_dicts. + """ + return value + + def to_python(self, value): + """ + Convert 'value' from a simple (JSON-serialisable) value to a (possibly complex) Python value to be + used in the rest of the block API and within front-end templates . In simple cases this might be + the value itself; alternatively, it might be a 'smart' version of the value which behaves mostly + like the original value but provides a native HTML rendering when inserted into a template; or it + might be something totally different (e.g. an image chooser will use the image ID as the clean + value, and turn this back into an actual image object here). + """ + return value + + def get_prep_value(self, value): + """ + The reverse of to_python; convert the python value into JSON-serialisable form. + """ + return value + + def render(self, value): + """ + Return a text rendering of 'value', suitable for display on templates. By default, this will + use a template if a 'template' property is specified on the block, and fall back on render_basic + otherwise. + """ + template = getattr(self.meta, 'template', None) + if template: + return render_to_string(template, {'self': value}) + else: + return self.render_basic(value) + + def render_basic(self, value): + """ + Return a text rendering of 'value', suitable for display on templates. render() will fall back on + this if the block does not define a 'template' property. + """ + return force_text(value) + + def __eq__(self, other): + """ + The deep_deconstruct method in django.db.migrations.autodetector.MigrationAutodetector does not + recurse into arbitrary lists and dicts. As a result, when it is passed a field such as: + StreamField([ + ('heading', CharBlock()), + ]) + the CharBlock object will be left in its constructed form. This causes problems when + MigrationAutodetector compares two separate instances of the StreamField from different project + states: since the CharBlocks are different objects, it will report a change where there isn't one. + + To prevent this, we implement the equality operator on Block instances such that the two CharBlocks + are reported as equal. Since block objects are intended to be immutable with the exception of + set_name(), it is sufficient to compare the 'name' property and the constructor args/kwargs of the + two block objects. The 'deconstruct' method provides a convenient way to access the latter. + """ + + if not isinstance(other, Block): + # if the other object isn't a block at all, it clearly isn't equal. + return False + + # Note that we do not require the two blocks to be of the exact same class. This is because + # we may wish the following blocks to be considered equal: + # + # class FooBlock(StructBlock): + # first_name = CharBlock() + # surname = CharBlock() + # + # class BarBlock(StructBlock): + # first_name = CharBlock() + # surname = CharBlock() + # + # FooBlock() == BarBlock() == StructBlock([('first_name', CharBlock()), ('surname': CharBlock())]) + # + # For this to work, StructBlock will need to ensure that 'deconstruct' returns the same signature + # in all of these cases, including reporting StructBlock as the path: + # + # FooBlock().deconstruct() == ( + # 'wagtail.wagtailcore.blocks.StructBlock', + # [('first_name', CharBlock()), ('surname': CharBlock())], + # {} + # ) + # + # This has the bonus side effect that the StructBlock field definition gets frozen into + # the migration, rather than leaving the migration vulnerable to future changes to FooBlock / BarBlock + # in models.py. + + return (self.name == other.name) and (self.deconstruct() == other.deconstruct()) + + def __ne__(self, other): + return not self.__eq__(other) + + # Making block instances hashable in a way that's consistent with __eq__ is non-trivial, because + # self.deconstruct() is liable to contain unhashable data (e.g. lists and dicts). So let's set + # Block to be explicitly unhashable - Python 3 will do this automatically when defining __eq__, + # but Python 2 won't, and we'd like the behaviour to be consistent on both. + __hash__ = None + + +class BoundBlock(object): + def __init__(self, block, value, prefix=None, errors=None): + self.block = block + self.value = value + self.prefix = prefix + self.errors = errors + + def render_form(self): + return self.block.render_form(self.value, self.prefix, errors=self.errors) + + def render(self): + return self.block.render(self.value) + + +# =========== +# Field block +# =========== + +# FIXME: form field instances are not deconstructible for migrations. Need some other way to refer to +# them in the initialiser, in the case that FieldBlock appears inline within a StreamField definition. +# (Referring to them by class would probably work; it's unlikely that any parameter passed to them +# would affect anything you're doing in migrations) + +class FieldBlock(Block): + class Meta: + default = None + + def render_form(self, value, prefix='', errors=None): + widget = self.field.widget + + if self.label: + label_html = format_html( + """ """, + label_id=widget.id_for_label(prefix), label=self.label + ) + else: + label_html = '' + + widget_attrs = {'id': prefix, 'placeholder': self.label} + + if hasattr(widget, 'render_with_errors'): + widget_html = widget.render_with_errors(prefix, value, attrs=widget_attrs, errors=errors) + widget_has_rendered_errors = True + else: + widget_html = widget.render(prefix, value, attrs=widget_attrs) + widget_has_rendered_errors = False + + return render_to_string('wagtailadmin/block_forms/field.html', { + 'name': self.name, + 'label': self.label, + 'classes': self.meta.classname, + 'widget': widget_html, + 'label_tag': label_html, + 'field': self.field, + 'errors': errors if (not widget_has_rendered_errors) else None + }) + + def value_from_datadict(self, data, files, prefix): + return self.to_python(self.field.widget.value_from_datadict(data, files, prefix)) + + def clean(self, value): + return self.field.clean(value) + +class CharBlock(FieldBlock): + def __init__(self, required=True, help_text=None, max_length=None, min_length=None, **kwargs): + # TODO: decide what to do about 'label' and 'initial' parameters to the form field + self.field = forms.CharField(required=required, help_text=help_text, max_length=max_length, min_length=min_length) + super(CharBlock, self).__init__(**kwargs) + +class URLBlock(FieldBlock): + def __init__(self, required=True, help_text=None, max_length=None, min_length=None, **kwargs): + self.field = forms.URLField(required=required, help_text=help_text, max_length=max_length, min_length=min_length) + super(URLBlock, self).__init__(**kwargs) + +class RichTextBlock(FieldBlock): + @cached_property + def field(self): + from wagtail.wagtailcore.fields import RichTextArea + return forms.CharField(widget=RichTextArea) + + def render_basic(self, value): + return mark_safe('
    ' + expand_db_html(value) + '
    ') + +class RawHTMLBlock(FieldBlock): + def __init__(self, required=True, help_text=None, max_length=None, min_length=None, **kwargs): + self.field = forms.CharField( + required=required, help_text=help_text, max_length=max_length, min_length=min_length, + widget = forms.Textarea) + super(RawHTMLBlock, self).__init__(**kwargs) + + def render_basic(self, value): + return mark_safe(value) # if it isn't safe, that's the site admin's problem for allowing raw HTML blocks in the first place... + + class Meta: + icon = 'code' + + +class ChooserBlock(FieldBlock): + def __init__(self, required=True, **kwargs): + self.required=required + super(ChooserBlock, self).__init__(**kwargs) + + """Abstract superclass for fields that implement a chooser interface (page, image, snippet etc)""" + @cached_property + def field(self): + return forms.ModelChoiceField(queryset=self.target_model.objects.all(), widget=self.widget, required=self.required) + + def to_python(self, value): + if value is None or isinstance(value, self.target_model): + return value + else: + try: + return self.target_model.objects.get(pk=value) + except self.target_model.DoesNotExist: + return None + + def get_prep_value(self, value): + if isinstance(value, self.target_model): + return value.id + else: + return value + + def clean(self, value): + # ChooserBlock works natively with model instances as its 'value' type (because that's what you + # want to work with when doing front-end templating), but ModelChoiceField.clean expects an ID + # as the input value (and returns a model instance as the result). We don't want to bypass + # ModelChoiceField.clean entirely (it might be doing relevant validation, such as checking page + # type) so we convert our instance back to an ID here. It means we have a wasted round-trip to + # the database when ModelChoiceField.clean promptly does its own lookup, but there's no easy way + # around that... + if isinstance(value, self.target_model): + value = value.pk + return super(ChooserBlock, self).clean(value) + +class PageChooserBlock(ChooserBlock): + @cached_property + def target_model(self): + from wagtail.wagtailcore.models import Page # TODO: allow limiting to specific page types + return Page + + @cached_property + def widget(self): + from wagtail.wagtailadmin.widgets import AdminPageChooser + return AdminPageChooser + + def render_basic(self, value): + if value: + return format_html('{1}', value.url, value.title) + else: + return '' + + +# =========== +# StructBlock +# =========== + +class BaseStructBlock(Block): + class Meta: + default = {} + template = "wagtailadmin/blocks/struct.html" + + def __init__(self, local_blocks=None, **kwargs): + super(BaseStructBlock, 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.child_js_initializers = {} + for name, block in self.child_blocks.items(): + js_initializer = block.js_initializer() + if js_initializer is not None: + self.child_js_initializers[name] = js_initializer + + self.dependencies = self.child_blocks.values() + + def js_initializer(self): + # skip JS setup entirely if no children have js_initializers + if not self.child_js_initializers: + return None + + return "StructBlock(%s)" % js_dict(self.child_js_initializers) + + @property + def media(self): + return forms.Media(js=['wagtailadmin/js/blocks/struct.js']) + + def render_form(self, value, prefix='', errors=None): + if errors: + if len(errors) > 1: + # We rely on StructBlock.clean throwing a single ValidationError with a specially crafted + # 'params' attribute that we can pull apart and distribute to the child blocks + raise TypeError('StructBlock.render_form unexpectedly received multiple errors') + error_dict = errors.as_data()[0].params + else: + error_dict = {} + + child_renderings = [ + block.render_form(value.get(name, block.meta.default), prefix="%s-%s" % (prefix, name), + errors=error_dict.get(name)) + for name, block in self.child_blocks.items() + ] + + list_items = format_html_join('\n', "
  • {0}
  • ", [ + [child_rendering] + for child_rendering in child_renderings + ]) + + + # Can these be rendered with a template? + if self.label: + return format_html('
    ', self.label, list_items) + else: + return format_html('
    ', list_items) + + def value_from_datadict(self, data, files, prefix): + return dict([ + (name, block.value_from_datadict(data, files, '%s-%s' % (prefix, name))) + for name, block in self.child_blocks.items() + ]) + + def clean(self, value): + result = {} + errors = {} + for name, val in value.items(): + try: + result[name] = self.child_blocks[name].clean(val) + except ValidationError as e: + errors[name] = ErrorList([e]) + + if errors: + # The message here is arbitrary - StructBlock.render_form will suppress it + # and delegate the errors contained in the 'params' dict to the child blocks instead + raise ValidationError('Validation error in StructBlock', params=errors) + + return result + + def to_python(self, value): + # recursively call to_python on children and return as a StructValue + return StructValue(self, [ + ( + name, + child_block.to_python(value.get(name, child_block.meta.default)) + ) + for name, child_block in self.child_blocks.items() + ]) + + def get_prep_value(self, value): + # recursively call get_prep_value on children and return as a plain dict + return dict([ + (name, self.child_blocks[name].get_prep_value(val)) + for name, val in value.items() + ]) + +@python_2_unicode_compatible # provide equivalent __unicode__ and __str__ methods on Py2 +class StructValue(collections.OrderedDict): + def __init__(self, block, *args): + super(StructValue, self).__init__(*args) + self.block = block + + def __str__(self): + return self.block.render(self) + + @cached_property + def bound_blocks(self): + return collections.OrderedDict([ + (name, block.bind(self.get(name))) + for name, block in self.block.child_blocks.items() + ]) + + +class DeclarativeSubBlocksMetaclass(BaseBlock): + """ + Metaclass that collects sub-blocks declared on the base classes. + (cheerfully stolen from https://github.com/django/django/blob/master/django/forms/forms.py) + """ + def __new__(mcs, name, bases, attrs): + # Collect sub-blocks from current class. + current_blocks = [] + for key, value in list(attrs.items()): + if isinstance(value, Block): + current_blocks.append((key, value)) + value.set_name(key) + attrs.pop(key) + current_blocks.sort(key=lambda x: x[1].creation_counter) + attrs['declared_blocks'] = collections.OrderedDict(current_blocks) + + new_class = (super(DeclarativeSubBlocksMetaclass, mcs) + .__new__(mcs, name, bases, attrs)) + + # Walk through the MRO. + declared_blocks = collections.OrderedDict() + for base in reversed(new_class.__mro__): + # Collect sub-blocks from base class. + if hasattr(base, 'declared_blocks'): + declared_blocks.update(base.declared_blocks) + + # Field shadowing. + for attr, value in base.__dict__.items(): + if value is None and attr in declared_blocks: + declared_blocks.pop(attr) + + new_class.base_blocks = declared_blocks + new_class.declared_blocks = declared_blocks + + return new_class + +class StructBlock(six.with_metaclass(DeclarativeSubBlocksMetaclass, BaseStructBlock)): + pass + + +# ========= +# ListBlock +# ========= + +class ListBlock(Block): + class Meta: + # Default to a list consisting of one empty child item (using None to trigger the child's empty / default rendering) + default = [None] + + def __init__(self, child_block, **kwargs): + super(ListBlock, self).__init__(**kwargs) + + if isinstance(child_block, type): + # child_block was passed as a class, so convert it to a block instance + self.child_block = child_block() + else: + self.child_block = child_block + + self.dependencies = [self.child_block] + self.child_js_initializer = self.child_block.js_initializer() + + @property + def media(self): + return forms.Media(js=['wagtailadmin/js/blocks/sequence.js', 'wagtailadmin/js/blocks/list.js']) + + def render_list_member(self, value, prefix, index, errors=None): + """ + Render the HTML for a single list item in the form. This consists of an
  • wrapper, hidden fields + to manage ID/deleted state, delete/reorder buttons, and the child block's own form HTML. + """ + child = self.child_block.bind(value, prefix="%s-value" % prefix, errors=errors) + return render_to_string('wagtailadmin/block_forms/list_member.html', { + 'prefix': prefix, + 'child': child, + 'index': index, + }) + + def html_declarations(self): + # generate the HTML to be used when adding a new item to the list; + # this is the output of render_list_member as rendered with the prefix '__PREFIX__' + # (to be replaced dynamically when adding the new item) and the child block's default value + # as its value. + list_member_html = self.render_list_member(self.child_block.meta.default, '__PREFIX__', '') + + return format_html( + '', + self.definition_prefix, mark_safe(escape_script(list_member_html)) + ) + + def js_initializer(self): + opts = {'definitionPrefix': "'%s'" % self.definition_prefix} + + if self.child_js_initializer: + opts['childInitializer'] = self.child_js_initializer + + return "ListBlock(%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 + + list_members_html = [ + self.render_list_member(child_val, "%s-%d" % (prefix, i), i, + errors=error_list[i] if error_list else None) + for (i, child_val) in enumerate(value) + ] + + return render_to_string('wagtailadmin/block_forms/list.html', { + 'label': self.label, + 'prefix': prefix, + 'list_members_html': list_members_html, + }) + + 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 + values_with_indexes.append( + ( + data['%s-%d-order' % (prefix, i)], + self.child_block.value_from_datadict(data, files, '%s-%d-value' % (prefix, i)) + ) + ) + + values_with_indexes.sort() + return [v for (i, v) in values_with_indexes] + + def clean(self, value): + result = [] + errors = [] + for child_val in value: + try: + result.append(self.child_block.clean(child_val)) + 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 ListBlock', params=errors) + + return result + + def to_python(self, value): + # recursively call to_python on children and return as a list + return [ + self.child_block.to_python(item) + for item in value + ] + + def get_prep_value(self, value): + # recursively call get_prep_value on children and return as a list + return [ + self.child_block.get_prep_value(item) + for item in value + ] + + def render_basic(self, value): + children = format_html_join('\n', '
  • {0}
  • ', + [(self.child_block.render(child_value),) for child_value in value] + ) + return format_html("", children) + + +# =========== +# StreamBlock +# =========== + +class BaseStreamBlock(Block): + # TODO: decide what it means to pass a 'default' arg to StreamBlock's constructor. Logically we want it to be + # of type StreamValue, but we can't construct one of those because it needs a reference back to the StreamBlock + # that we haven't constructed yet... + class Meta: + @property + def default(self): + return StreamValue(self, []) + + def __init__(self, local_blocks=None, **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 render_list_member(self, block_type_name, value, prefix, index, errors=None): + """ + Render the HTML for a single list item. This consists of an
  • 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', '', + [ + ( + self.definition_prefix, + name, + mark_safe(escape_script(self.render_list_member(name, child_block.meta.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', { + 'label': self.label, + '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, [ + (block_type_name, value) + for (index, 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. + # Convert this to a StreamValue backed by a list of (type, value) tuples + return StreamValue(self, [ + (child_data['type'], self.child_blocks[child_data['type']].to_python(child_data['value'])) + for child_data in value + if child_data['type'] in self.child_blocks + ]) + + def get_prep_value(self, value): + if value is None: + return None + + 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', '
    {0}
    ', + [(child, child.block_type) for child in value] + ) + + +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): + 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__ + + def __getitem__(self, i): + if i not in self._bound_blocks: + 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 __str__(self): + return self.stream_block.render(self) + + +# ======================== +# django.forms integration +# ======================== + +class BlockWidget(forms.Widget): + """Wraps a block object as a widget so that it can be incorporated into a Django form""" + def __init__(self, block_def, attrs=None): + super(BlockWidget, self).__init__(attrs=attrs) + self.block_def = block_def + + def render_with_errors(self, name, value, attrs=None, errors=None): + bound_block = self.block_def.bind(value, prefix=name, errors=errors) + js_initializer = self.block_def.js_initializer() + if js_initializer: + js_snippet = """ + + """ % (js_initializer, name) + else: + js_snippet = '' + return mark_safe(bound_block.render_form() + js_snippet) + + def render(self, name, value, attrs=None): + return self.render_with_errors(name, value, attrs=attrs, errors=None) + + @property + def media(self): + return self.block_def.all_media() + + def value_from_datadict(self, data, files, name): + return self.block_def.value_from_datadict(data, files, name) + + +class BlockField(forms.Field): + """Wraps a block object as a form field so that it can be incorporated into a Django form""" + def __init__(self, block=None, **kwargs): + if block is None: + raise ImproperlyConfigured("BlockField was not passed a 'block' object") + self.block = block + + if 'widget' not in kwargs: + kwargs['widget'] = BlockWidget(block) + + super(BlockField, self).__init__(**kwargs) + + def clean(self, value): + return self.block.clean(value) diff --git a/wagtail/wagtailcore/fields.py b/wagtail/wagtailcore/fields.py index 80dbc96b7..9de1ec81c 100644 --- a/wagtail/wagtailcore/fields.py +++ b/wagtail/wagtailcore/fields.py @@ -8,7 +8,7 @@ from django.utils.six import with_metaclass from wagtail.wagtailcore.rich_text import DbWhitelister, expand_db_html from wagtail.utils.widgets import WidgetWithScript -from wagtail.wagtailadmin.blocks import Block, StreamBlock, StreamValue, BlockField # FIXME: wagtailcore shouldn't be depending on wagtailadmin +from wagtail.wagtailcore.blocks import Block, StreamBlock, StreamValue, BlockField class RichTextArea(WidgetWithScript, forms.Textarea): diff --git a/wagtail/wagtailadmin/tests/test_blocks.py b/wagtail/wagtailcore/tests/test_blocks.py similarity index 99% rename from wagtail/wagtailadmin/tests/test_blocks.py rename to wagtail/wagtailcore/tests/test_blocks.py index 143106c0e..9f2bf7179 100644 --- a/wagtail/wagtailadmin/tests/test_blocks.py +++ b/wagtail/wagtailcore/tests/test_blocks.py @@ -4,7 +4,7 @@ from django import forms from django.forms.utils import ErrorList from django.core.exceptions import ValidationError -from wagtail.wagtailadmin import blocks +from wagtail.wagtailcore import blocks class TestFieldBlock(unittest.TestCase): diff --git a/wagtail/wagtaildocs/blocks.py b/wagtail/wagtaildocs/blocks.py index 07e893d74..38b9f93ee 100644 --- a/wagtail/wagtaildocs/blocks.py +++ b/wagtail/wagtaildocs/blocks.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django.utils.functional import cached_property from django.utils.html import format_html -from wagtail.wagtailadmin.blocks import ChooserBlock +from wagtail.wagtailcore.blocks import ChooserBlock class DocumentChooserBlock(ChooserBlock): @cached_property diff --git a/wagtail/wagtailembeds/blocks.py b/wagtail/wagtailembeds/blocks.py index 4a6b8a71b..4ab7debc6 100644 --- a/wagtail/wagtailembeds/blocks.py +++ b/wagtail/wagtailembeds/blocks.py @@ -1,4 +1,4 @@ -from wagtail.wagtailadmin import blocks +from wagtail.wagtailcore import blocks from wagtail.wagtailembeds.format import embed_to_frontend_html diff --git a/wagtail/wagtailimages/blocks.py b/wagtail/wagtailimages/blocks.py index 9e06ef28d..b58d1c32e 100644 --- a/wagtail/wagtailimages/blocks.py +++ b/wagtail/wagtailimages/blocks.py @@ -1,6 +1,6 @@ from django.utils.functional import cached_property -from wagtail.wagtailadmin.blocks import ChooserBlock +from wagtail.wagtailcore.blocks import ChooserBlock class ImageChooserBlock(ChooserBlock): @cached_property diff --git a/wagtail/wagtailsnippets/blocks.py b/wagtail/wagtailsnippets/blocks.py index 560cdf7f7..288e30fa2 100644 --- a/wagtail/wagtailsnippets/blocks.py +++ b/wagtail/wagtailsnippets/blocks.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django.utils.functional import cached_property from django.contrib.contenttypes.models import ContentType -from wagtail.wagtailadmin.blocks import ChooserBlock +from wagtail.wagtailcore.blocks import ChooserBlock class SnippetChooserBlock(ChooserBlock): def __init__(self, target_model, **kwargs):