Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# Import block types defined in submodules into the wagtail.blocks namespace
from .base import * # NOQA: F403
from .field_block import * # NOQA: F403
from .list_block import * # NOQA: F403
from .static_block import * # NOQA: F403
from .stream_block import * # NOQA: F403
from .struct_block import * # NOQA: F403

View File

@@ -0,0 +1,705 @@
import collections
import itertools
import json
import re
import warnings
from functools import lru_cache
from importlib import import_module
from django import forms
from django.core import checks
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import render_to_string
from django.utils.encoding import force_str
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from wagtail.admin.staticfiles import versioned_static
from wagtail.coreutils import accepts_kwarg
from wagtail.telepath import JSContext
from wagtail.utils.deprecation import RemovedInWagtail70Warning
__all__ = [
"BaseBlock",
"Block",
"BoundBlock",
"DeclarativeSubBlocksMetaclass",
"BlockWidget",
"BlockField",
]
# =========================================
# Top-level superclasses and helper objects
# =========================================
class BaseBlock(type):
def __new__(mcs, name, bases, attrs):
meta_class = attrs.pop("Meta", None)
cls = super().__new__(mcs, name, bases, attrs)
# Get all the Meta classes from all the bases
meta_class_bases = [meta_class] + [
getattr(base, "_meta_class", None) for base in bases
]
meta_class_bases = tuple(filter(bool, meta_class_bases))
cls._meta_class = type(str(name + "Meta"), meta_class_bases, {})
return cls
class Block(metaclass=BaseBlock):
name = ""
creation_counter = 0
TEMPLATE_VAR = "value"
class Meta:
label = None
icon = "placeholder"
classname = None
group = ""
# Attributes of Meta which can legally be modified after the block has been instantiated.
# Used to implement __eq__. label is not included here, despite it technically being mutable via
# set_name, since its value must originate from either the constructor arguments or set_name,
# both of which are captured by the equality test, so checking label as well would be redundant.
MUTABLE_META_ATTRIBUTES = []
def __new__(cls, *args, **kwargs):
# adapted from django.utils.deconstruct.deconstructible; capture the arguments
# so that we can return them in the 'deconstruct' method
obj = super().__new__(cls)
obj._constructor_args = (args, kwargs)
return obj
def __init__(self, **kwargs):
if "classname" in self._constructor_args[1]:
# Adding this so that migrations are not triggered
# when form_classname is used instead of classname
# in the initialisation of the FieldBlock
classname = self._constructor_args[1].pop("classname")
self._constructor_args[1].setdefault("form_classname", classname)
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 ""
@classmethod
def construct_from_lookup(cls, lookup, *args, **kwargs):
"""
See `wagtail.blocks.definition_lookup.BlockDefinitionLookup`.
Construct a block instance from the provided arguments, using the given BlockDefinitionLookup
object to perform any necessary lookups.
"""
# In the base implementation, no lookups take place - args / kwargs are passed
# on to the constructor as-is
return cls(*args, **kwargs)
def set_name(self, name):
self.name = name
if not self.meta.label:
self.label = capfirst(force_str(name).replace("_", " "))
def set_meta_options(self, opts):
"""
Update this block's meta options (out of the ones designated as mutable) from the given dict.
Used by the StreamField constructor to pass on kwargs that are to be handled by the block,
since the block object has already been created by that point, e.g.:
body = StreamField(SomeStreamBlock(), max_num=5)
"""
for attr, value in opts.items():
if attr in self.MUTABLE_META_ATTRIBUTES:
setattr(self.meta, attr, value)
else:
raise TypeError(
"set_meta_options received unexpected option: %r" % attr
)
def value_from_datadict(self, data, files, prefix):
raise NotImplementedError("%s.value_from_datadict" % self.__class__)
def value_omitted_from_data(self, data, files, name):
"""
Used only for top-level blocks wrapped by BlockWidget (i.e.: typically only StreamBlock)
to inform ModelForm logic on Django >=1.10.2 whether the field is absent from the form
submission (and should therefore revert to the field default).
"""
return name not in data
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 get_default(self):
"""
Return this block's default value (conventionally found in self.meta.default),
converted to the value type expected by this block. This caters for the case
where that value type is not something that can be expressed statically at
model definition time (e.g. something like StructValue which incorporates a
pointer back to the block definition object).
"""
return self.normalize(self.meta.default)
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; the ValidationError must therefore include all detail necessary to perform that
rendering, such as identifying the specific child block(s) with errors, in the case of nested
blocks. (It is suggested that you use the 'params' attribute for this; using error_list /
error_dict is unreliable because Django tends to hack around with these when nested.)
"""
return value
def normalize(self, value):
"""
Given a value for any acceptable type for this block (e.g. string or RichText for a RichTextBlock;
dict or StructValue for a StructBlock), return a value of the block's native type (e.g. RichText
for RichTextBlock, StructValue for StructBlock). In simple cases this will return the value
unchanged.
"""
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).
For blocks that are usable at the top level of a StreamField, this must also accept any type accepted
by normalize. (This is because Django calls `Field.to_python` from `Field.clean`.)
"""
return value
def bulk_to_python(self, values):
"""
Apply the to_python conversion to a list of values. The default implementation simply
iterates over the list; subclasses may optimise this, e.g. by combining database lookups
into a single query.
"""
return [self.to_python(value) for value in values]
def get_prep_value(self, value):
"""
The reverse of to_python; convert the python value into JSON-serialisable form.
"""
return value
def get_form_state(self, value):
"""
Convert a python value for this block into a JSON-serialisable representation containing
all the data needed to present the value in a form field, to be received by the block's
client-side component. Examples of where this conversion is not trivial include rich text
(where it needs to be supplied in a format that the editor can process, e.g. ContentState
for Draftail) and page / image / document choosers (where it needs to include all displayed
data for the selected item, such as title or thumbnail).
"""
return value
def get_context(self, value, parent_context=None):
"""
Return a dict of context variables (derived from the block value and combined with the parent_context)
to be used as the template context when rendering this value through a template.
"""
context = parent_context or {}
context.update(
{
"self": value,
self.TEMPLATE_VAR: value,
}
)
return context
def get_template(self, value=None, context=None):
"""
Return the template to use for rendering the block if specified on meta class.
This extraction was added to make dynamic templates possible if you override this method
value contains the current value of the block, allowing overridden methods to
select the proper template based on the actual block value.
"""
return getattr(self.meta, "template", None)
def render(self, value, context=None):
"""
Return a text rendering of 'value', suitable for display on templates. By default, this will
use a template (with the passed context, supplemented by the result of get_context) if a
'template' property is specified on the block, and fall back on render_basic otherwise.
"""
args = {"context": context}
if accepts_kwarg(self.get_template, "value"):
args["value"] = value
else:
warnings.warn(
f"{self.__class__.__name__}.get_template should accept a 'value' argument as first argument",
RemovedInWagtail70Warning,
)
template = self.get_template(**args)
if not template:
return self.render_basic(value, context=context)
if context is None:
new_context = self.get_context(value)
else:
new_context = self.get_context(value, parent_context=dict(context))
return mark_safe(render_to_string(template, new_context))
def get_api_representation(self, value, context=None):
"""
Can be used to customise the API response and defaults to the value returned by get_prep_value.
"""
return self.get_prep_value(value)
def render_basic(self, value, context=None):
"""
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_str(value)
def get_searchable_content(self, value):
"""
Returns a list of strings containing text content within this block to be used in a search engine.
"""
return []
def extract_references(self, value):
return []
def get_block_by_content_path(self, value, path_elements):
"""
Given a list of elements from a content path, retrieve the block at that path
as a BoundBlock object, or None if the path does not correspond to a valid block.
"""
# In the base case, where a block has no concept of children, the only valid path is
# the empty one (which refers to the current block).
if path_elements:
return None
else:
return self.bind(value)
def check(self, **kwargs):
"""
Hook for the Django system checks framework -
returns a list of django.core.checks.Error objects indicating validity errors in the block
"""
return []
def _check_name(self, **kwargs):
"""
Helper method called by container blocks as part of the system checks framework,
to validate that this block's name is a valid identifier.
(Not called universally, because not all blocks need names)
"""
errors = []
if not self.name:
errors.append(
checks.Error(
"Block name %r is invalid" % self.name,
hint="Block name cannot be empty",
obj=kwargs.get("field", self),
id="wagtailcore.E001",
)
)
if " " in self.name:
errors.append(
checks.Error(
"Block name %r is invalid" % self.name,
hint="Block names cannot contain spaces",
obj=kwargs.get("field", self),
id="wagtailcore.E001",
)
)
if "-" in self.name:
errors.append(
checks.Error(
"Block name %r is invalid" % self.name,
"Block names cannot contain dashes",
obj=kwargs.get("field", self),
id="wagtailcore.E001",
)
)
if self.name and self.name[0].isdigit():
errors.append(
checks.Error(
"Block name %r is invalid" % self.name,
"Block names cannot begin with a digit",
obj=kwargs.get("field", self),
id="wagtailcore.E001",
)
)
if not errors and not re.match(r"^[_a-zA-Z][_a-zA-Z0-9]*$", self.name):
errors.append(
checks.Error(
"Block name %r is invalid" % self.name,
"Block names should follow standard Python conventions for "
"variable names: alphanumeric and underscores, and cannot "
"begin with a digit",
obj=kwargs.get("field", self),
id="wagtailcore.E001",
)
)
return errors
def id_for_label(self, prefix):
"""
Return the ID to be used as the 'for' attribute of <label> elements that refer to this block,
when the given field prefix is in use. Return None if no 'for' attribute should be used.
"""
return None
@property
def required(self):
"""
Flag used to determine whether labels for this block should display a 'required' asterisk.
False by default, since Block does not provide any validation of its own - it's up to subclasses
to define what required-ness means.
"""
return False
@cached_property
def canonical_module_path(self):
"""
Return the module path string that should be used to refer to this block in migrations.
"""
# adapted from django.utils.deconstruct.deconstructible
module_name = self.__module__
name = self.__class__.__name__
# Make sure it's actually there and not an inner class
module = import_module(module_name)
if not hasattr(module, name):
raise ValueError(
"Could not find object %s in %s.\n"
"Please note that you cannot serialize things like inner "
"classes. Please move the object into the main module "
"body to use migrations.\n" % (name, module_name)
)
# if the module defines a DECONSTRUCT_ALIASES dictionary, see if the class has an entry in there;
# if so, use that instead of the real path
try:
return module.DECONSTRUCT_ALIASES[self.__class__]
except (AttributeError, KeyError):
return f"{module_name}.{name}"
def deconstruct(self):
return (
self.canonical_module_path,
self._constructor_args[0],
self._constructor_args[1],
)
def deconstruct_with_lookup(self, lookup):
"""
Like `deconstruct`, but with a `wagtail.blocks.definition_lookup.BlockDefinitionLookupBuilder`
object available so that any block instances within the definition can be added to the lookup
table to obtain an ID (potentially shared with other matching block definitions, thus reducing
the overall definition size) to be used in place of the block. The resulting deconstructed form
returned here can then be restored into a block object using `Block.construct_from_lookup`.
"""
# In the base implementation, no substitutions happen, so we ignore the lookup and just call
# deconstruct
return self.deconstruct()
def __eq__(self, other):
"""
Implement equality on block objects so that two blocks with matching definitions are considered
equal. Block objects are intended to be immutable with the exception of set_name() and any meta
attributes identified in MUTABLE_META_ATTRIBUTES, so checking these along with the result of
deconstruct (which captures the constructor arguments) is sufficient to identify (valid) differences.
This was implemented as a workaround for a Django <1.9 bug and is quite possibly not used by Wagtail
any more, but has been retained as it provides a sensible definition of equality (and there's no
reason to break it).
"""
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.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()
and all(
getattr(self.meta, attr, None) == getattr(other.meta, attr, None)
for attr in self.MUTABLE_META_ATTRIBUTES
)
)
class BoundBlock:
def __init__(self, block, value, prefix=None, errors=None):
self.block = block
self.value = value
self.prefix = prefix
self.errors = errors
def render(self, context=None):
return self.block.render(self.value, context=context)
def render_as_block(self, context=None):
"""
Alias for render; the include_block tag will specifically check for the presence of a method
with this name. (This is because {% include_block %} is just as likely to be invoked on a bare
value as a BoundBlock. If we looked for a `render` method instead, we'd run the risk of finding
an unrelated method that just happened to have that name - for example, when called on a
PageChooserBlock it could end up calling page.render.
"""
return self.block.render(self.value, context=context)
def id_for_label(self):
return self.block.id_for_label(self.prefix)
def __str__(self):
"""Render the value according to the block's native rendering"""
return self.block.render(self.value)
def __repr__(self):
return "<block {}: {!r}>".format(
self.block.name or type(self.block).__name__,
self.value,
)
class DeclarativeSubBlocksMetaclass(BaseBlock):
"""
Metaclass that collects sub-blocks declared on the base classes.
(cheerfully stolen from https://github.com/django/django/blob/main/django/forms/forms.py)
"""
def __new__(mcs, name, bases, attrs):
# Collect sub-blocks declared on the current class.
# These are available on the class as `declared_blocks`
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().__new__(mcs, name, bases, attrs)
# Walk through the MRO, collecting all inherited sub-blocks, to make
# the combined `base_blocks`.
base_blocks = collections.OrderedDict()
for base in reversed(new_class.__mro__):
# Collect sub-blocks from base class.
if hasattr(base, "declared_blocks"):
base_blocks.update(base.declared_blocks)
# Field shadowing.
for attr, value in base.__dict__.items():
if value is None and attr in base_blocks:
base_blocks.pop(attr)
new_class.base_blocks = base_blocks
return new_class
# ========================
# 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().__init__(attrs=attrs)
self.block_def = block_def
self._js_context = None
self._block_json = None
def _build_block_json(self):
try:
self._js_context = JSContext()
self._block_json = json.dumps(self._js_context.pack(self.block_def))
except Exception as e: # noqa: BLE001
raise ValueError("Error while serializing block definition: %s" % e) from e
@property
def js_context(self):
if self._js_context is None:
self._build_block_json()
return self._js_context
@property
def block_json(self):
if self._block_json is None:
self._build_block_json()
return self._block_json
def id_for_label(self, prefix):
# Delegate the job of choosing a label ID to the top-level block.
# (In practice, the top-level block will typically be a StreamBlock, which returns None.)
return self.block_def.id_for_label(prefix)
def render_with_errors(self, name, value, attrs=None, errors=None, renderer=None):
value_json = json.dumps(self.block_def.get_form_state(value))
if errors:
# errors is expected to be an ErrorList consisting of a single validation error
error = errors.as_data()[0]
error_json = json.dumps(get_error_json_data(error))
else:
error_json = json.dumps(None)
return format_html(
"""
<div id="{id}" data-block data-controller="w-block" data-w-block-data-value="{block_json}" data-w-block-arguments-value="[{value_json},{error_json}]"></div>
""",
id=name,
block_json=self.block_json,
value_json=value_json,
error_json=error_json,
)
def render(self, name, value, attrs=None, renderer=None):
return self.render_with_errors(
name, value, attrs=attrs, errors=None, renderer=renderer
)
@cached_property
def media(self):
return self.js_context.media + forms.Media(
js=[
# needed for initBlockWidget, although these will almost certainly be
# pulled in by the block adapters too
versioned_static("wagtailadmin/js/telepath/telepath.js"),
versioned_static("wagtailadmin/js/telepath/blocks.js"),
],
css={
"all": [
versioned_static("wagtailadmin/css/panels/streamfield.css"),
]
},
)
def value_from_datadict(self, data, files, name):
return self.block_def.value_from_datadict(data, files, name)
def value_omitted_from_data(self, data, files, name):
return self.block_def.value_omitted_from_data(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().__init__(**kwargs)
def clean(self, value):
return self.block.clean(value)
def has_changed(self, initial_value, data_value):
return self.block.get_prep_value(initial_value) != self.block.get_prep_value(
data_value
)
@lru_cache(maxsize=None)
def get_help_icon():
return render_to_string(
"wagtailadmin/shared/icon.html", {"name": "help", "classname": "default"}
)
def get_error_json_data(error):
"""
Translate a ValidationError instance raised against a block (which may potentially be a
ValidationError subclass specialised for a particular block type) into a JSON-serialisable dict
consisting of one or both of:
messages: a list of error message strings to be displayed against the block
blockErrors: a structure specific to the block type, containing further error objects in this
format to be displayed against this block's children
"""
if hasattr(error, "as_json_data"):
return error.as_json_data()
else:
return {"messages": error.messages}
def get_error_list_json_data(error_list):
"""
Flatten an ErrorList instance containing any number of ValidationErrors
(which may themselves contain multiple messages) into a list of error message strings.
This does not consider any other properties of ValidationError other than `message`,
so should not be used where ValidationError subclasses with nested block errors may be
present.
(In terms of StreamBlockValidationError et al: it's valid for use on non_block_errors
but not block_errors)
"""
return list(itertools.chain(*(err.messages for err in error_list.as_data())))
DECONSTRUCT_ALIASES = {
Block: "wagtail.blocks.Block",
}

View File

@@ -0,0 +1,85 @@
from collections import defaultdict
from importlib import import_module
class BlockDefinitionLookup:
"""
A utility for constructing StreamField Block objects in migrations, starting from
a compact representation that avoids repeating the same definition whenever a
block is re-used in multiple places over the block definition tree.
The underlying data is a dict of block definitions, such as:
```
{
0: ("wagtail.blocks.CharBlock", [], {"required": True}),
1: ("wagtail.blocks.RichTextBlock", [], {}),
2: ("wagtail.blocks.StreamBlock", [
[
("heading", 0),
("paragraph", 1),
],
], {}),
}
```
where each definition is a tuple of (module_path, args, kwargs) similar to that
returned by `deconstruct` - with the difference that any block objects appearing
in args / kwargs may be substituted with an index into the lookup table that
points to that block's definition. Any block class that wants to support such
substitutions should implement a static/class method
`construct_from_lookup(lookup, *args, **kwargs)`, where `lookup` is
the `BlockDefinitionLookup` instance. The method should return a block instance
constructed from the provided arguments (after performing any lookups).
"""
def __init__(self, blocks):
self.blocks = blocks
self.block_classes = {}
def get_block(self, index):
path, args, kwargs = self.blocks[index]
try:
cls = self.block_classes[path]
except KeyError:
module_name, class_name = path.rsplit(".", 1)
module = import_module(module_name)
cls = self.block_classes[path] = getattr(module, class_name)
return cls.construct_from_lookup(self, *args, **kwargs)
class BlockDefinitionLookupBuilder:
"""
Helper for constructing the lookup data used by BlockDefinitionLookup
"""
def __init__(self):
self.blocks = []
# Lookup table mapping the deconstructed tuple forms of blocks (as obtained from
# `block.deconstruct_with_lookup`) to their index in the `blocks` list. These
# tuples can be compared for equality, but not hashed, so we cannot use them as
# dict keys; instead, we index them on the first tuple element (the module path)
# and maintain a list of (index, deconstructed_tuple) pairs for each one.
self.block_indexes_by_type = defaultdict(list)
def add_block(self, block):
"""
Add a block to the lookup table, returning an index that can be used to refer to it
"""
deconstructed = block.deconstruct_with_lookup(self)
# Check if we've already seen this block definition
block_indexes = self.block_indexes_by_type[deconstructed[0]]
for index, existing_deconstructed in block_indexes:
if existing_deconstructed == deconstructed:
return index
# If not, add it to the lookup table
index = len(self.blocks)
self.blocks.append(deconstructed)
block_indexes.append((index, deconstructed))
return index
def get_lookup_as_dict(self):
return dict(enumerate(self.blocks))

View File

@@ -0,0 +1,982 @@
import datetime
from decimal import Decimal
from django import forms
from django.db.models import Model
from django.db.models.fields import BLANK_CHOICE_DASH
from django.utils.dateparse import parse_date, parse_datetime, parse_time
from django.utils.encoding import force_str
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from wagtail.admin.staticfiles import versioned_static
from wagtail.coreutils import camelcase_to_underscore, resolve_model_string
from wagtail.rich_text import (
RichText,
RichTextMaxLengthValidator,
extract_references_from_rich_text,
get_text_for_indexing,
)
from wagtail.telepath import Adapter, register
from .base import Block
try:
from django.utils.choices import CallableChoiceIterator
except ImportError:
# DJANGO_VERSION < 5.0
from django.forms.fields import CallableChoiceIterator
class FieldBlock(Block):
"""A block that wraps a Django form field"""
def id_for_label(self, prefix):
return self.field.widget.id_for_label(prefix)
def value_from_form(self, value):
"""
The value that we get back from the form field might not be the type
that this block works with natively; for example, the block may want to
wrap a simple value such as a string in an object that provides a fancy
HTML rendering (e.g. EmbedBlock).
We therefore provide this method to perform any necessary conversion
from the form field value to the block's native value. As standard,
this returns the form field value unchanged.
"""
return value
def value_for_form(self, value):
"""
Reverse of value_from_form; convert a value of this block's native value type
to one that can be rendered by the form field
"""
return value
def value_from_datadict(self, data, files, prefix):
return self.value_from_form(
self.field.widget.value_from_datadict(data, files, prefix)
)
def value_omitted_from_data(self, data, files, prefix):
return self.field.widget.value_omitted_from_data(data, files, prefix)
def clean(self, value):
# We need an annoying value_for_form -> value_from_form round trip here to account for
# the possibility that the form field is set up to validate a different value type to
# the one this block works with natively
return self.value_from_form(self.field.clean(self.value_for_form(value)))
@property
def required(self):
# a FieldBlock is required if and only if its underlying form field is required
return self.field.required
def get_form_state(self, value):
return self.field.widget.format_value(
self.field.prepare_value(self.value_for_form(value))
)
class Meta:
# No icon specified here, because that depends on the purpose that the
# block is being used for. Feel encouraged to specify an icon in your
# descendant block type
icon = "placeholder"
default = None
class FieldBlockAdapter(Adapter):
js_constructor = "wagtail.blocks.FieldBlock"
def js_args(self, block):
classname = [
"w-field",
f"w-field--{camelcase_to_underscore(block.field.__class__.__name__)}",
f"w-field--{camelcase_to_underscore(block.field.widget.__class__.__name__)}",
]
form_classname = getattr(block.meta, "form_classname", "")
if form_classname:
classname.append(form_classname)
# Provided for backwards compatibility. Replaced with 'form_classname'
legacy_classname = getattr(block.meta, "classname", "")
if legacy_classname:
classname.append(legacy_classname)
meta = {
"label": block.label,
"required": block.required,
"icon": block.meta.icon,
"classname": " ".join(classname),
"showAddCommentButton": getattr(
block.field.widget, "show_add_comment_button", True
),
"strings": {"ADD_COMMENT": _("Add Comment")},
}
if block.field.help_text:
meta["helpText"] = block.field.help_text
if hasattr(block, "max_length") and block.max_length is not None:
meta["maxLength"] = block.max_length
return [
block.name,
block.field.widget,
meta,
]
@cached_property
def media(self):
return forms.Media(
js=[
versioned_static("wagtailadmin/js/telepath/blocks.js"),
]
)
register(FieldBlockAdapter(), FieldBlock)
class CharBlock(FieldBlock):
def __init__(
self,
required=True,
help_text=None,
max_length=None,
min_length=None,
validators=(),
search_index=True,
**kwargs,
):
# CharField's 'label' and 'initial' parameters are not exposed, as Block handles that functionality natively
# (via 'label' and 'default')
self.search_index = search_index
self.field = forms.CharField(
required=required,
help_text=help_text,
max_length=max_length,
min_length=min_length,
validators=validators,
)
super().__init__(**kwargs)
def get_searchable_content(self, value):
return [force_str(value)] if self.search_index else []
class TextBlock(FieldBlock):
def __init__(
self,
required=True,
help_text=None,
rows=1,
max_length=None,
min_length=None,
search_index=True,
validators=(),
**kwargs,
):
self.field_options = {
"required": required,
"help_text": help_text,
"max_length": max_length,
"min_length": min_length,
"validators": validators,
}
self.rows = rows
self.search_index = search_index
super().__init__(**kwargs)
@cached_property
def field(self):
from wagtail.admin.widgets import AdminAutoHeightTextInput
field_kwargs = {"widget": AdminAutoHeightTextInput(attrs={"rows": self.rows})}
field_kwargs.update(self.field_options)
return forms.CharField(**field_kwargs)
def get_searchable_content(self, value):
return [force_str(value)] if self.search_index else []
class Meta:
icon = "pilcrow"
class BlockQuoteBlock(TextBlock):
def render_basic(self, value, context=None):
if value:
return format_html("<blockquote>{0}</blockquote>", value)
else:
return ""
class Meta:
icon = "openquote"
class FloatBlock(FieldBlock):
def __init__(
self,
required=True,
max_value=None,
min_value=None,
validators=(),
*args,
**kwargs,
):
self.field = forms.FloatField(
required=required,
max_value=max_value,
min_value=min_value,
validators=validators,
)
super().__init__(*args, **kwargs)
class Meta:
icon = "decimal"
class DecimalBlock(FieldBlock):
def __init__(
self,
required=True,
help_text=None,
max_value=None,
min_value=None,
max_digits=None,
decimal_places=None,
validators=(),
*args,
**kwargs,
):
self.field = forms.DecimalField(
required=required,
help_text=help_text,
max_value=max_value,
min_value=min_value,
max_digits=max_digits,
decimal_places=decimal_places,
validators=validators,
)
super().__init__(*args, **kwargs)
def to_python(self, value):
if value is None:
return value
else:
return Decimal(value)
class Meta:
icon = "decimal"
class RegexBlock(FieldBlock):
def __init__(
self,
regex,
required=True,
help_text=None,
max_length=None,
min_length=None,
error_messages=None,
validators=(),
*args,
**kwargs,
):
self.field = forms.RegexField(
regex=regex,
required=required,
help_text=help_text,
max_length=max_length,
min_length=min_length,
error_messages=error_messages,
validators=validators,
)
super().__init__(*args, **kwargs)
class Meta:
icon = "regex"
class URLBlock(FieldBlock):
def __init__(
self,
required=True,
help_text=None,
max_length=None,
min_length=None,
validators=(),
**kwargs,
):
self.field = forms.URLField(
required=required,
help_text=help_text,
max_length=max_length,
min_length=min_length,
validators=validators,
)
super().__init__(**kwargs)
class Meta:
icon = "link-external"
class BooleanBlock(FieldBlock):
def __init__(self, required=True, help_text=None, **kwargs):
# NOTE: As with forms.BooleanField, the default of required=True means that the checkbox
# must be ticked to pass validation (i.e. it's equivalent to an "I agree to the terms and
# conditions" box). To get the conventional yes/no behaviour, you must explicitly pass
# required=False.
self.field = forms.BooleanField(required=required, help_text=help_text)
super().__init__(**kwargs)
def get_form_state(self, value):
# Bypass widget.format_value, because CheckboxInput uses that to prepare the "value"
# attribute (as distinct from the "checked" attribute that represents the actual checkbox
# state, which it handles in get_context).
return bool(value)
class Meta:
icon = "tick-inverse"
class DateBlock(FieldBlock):
def __init__(
self, required=True, help_text=None, format=None, validators=(), **kwargs
):
self.field_options = {
"required": required,
"help_text": help_text,
"validators": validators,
}
try:
self.field_options["input_formats"] = kwargs.pop("input_formats")
except KeyError:
pass
self.format = format
super().__init__(**kwargs)
@cached_property
def field(self):
from wagtail.admin.widgets import AdminDateInput
field_kwargs = {
"widget": AdminDateInput(format=self.format),
}
field_kwargs.update(self.field_options)
return forms.DateField(**field_kwargs)
def to_python(self, value):
# Serialising to JSON uses DjangoJSONEncoder, which converts date/time objects to strings.
# The reverse does not happen on decoding, because there's no way to know which strings
# should be decoded; we have to convert strings back to dates here instead.
if value is None or isinstance(value, datetime.date):
return value
else:
return parse_date(value)
class Meta:
icon = "date"
class TimeBlock(FieldBlock):
def __init__(
self, required=True, help_text=None, format=None, validators=(), **kwargs
):
self.field_options = {
"required": required,
"help_text": help_text,
"validators": validators,
}
self.format = format
super().__init__(**kwargs)
@cached_property
def field(self):
from wagtail.admin.widgets import AdminTimeInput
field_kwargs = {"widget": AdminTimeInput(format=self.format)}
field_kwargs.update(self.field_options)
return forms.TimeField(**field_kwargs)
def to_python(self, value):
if value is None or isinstance(value, datetime.time):
return value
else:
return parse_time(value)
class Meta:
icon = "time"
class DateTimeBlock(FieldBlock):
def __init__(
self, required=True, help_text=None, format=None, validators=(), **kwargs
):
self.field_options = {
"required": required,
"help_text": help_text,
"validators": validators,
}
self.format = format
super().__init__(**kwargs)
@cached_property
def field(self):
from wagtail.admin.widgets import AdminDateTimeInput
field_kwargs = {
"widget": AdminDateTimeInput(format=self.format),
}
field_kwargs.update(self.field_options)
return forms.DateTimeField(**field_kwargs)
def to_python(self, value):
if value is None or isinstance(value, datetime.datetime):
return value
else:
return parse_datetime(value)
class Meta:
icon = "date"
class EmailBlock(FieldBlock):
def __init__(self, required=True, help_text=None, validators=(), **kwargs):
self.field = forms.EmailField(
required=required,
help_text=help_text,
validators=validators,
)
super().__init__(**kwargs)
class Meta:
icon = "mail"
class IntegerBlock(FieldBlock):
def __init__(
self,
required=True,
help_text=None,
min_value=None,
max_value=None,
validators=(),
**kwargs,
):
self.field = forms.IntegerField(
required=required,
help_text=help_text,
min_value=min_value,
max_value=max_value,
validators=validators,
)
super().__init__(**kwargs)
class Meta:
icon = "placeholder"
class BaseChoiceBlock(FieldBlock):
choices = ()
def __init__(
self,
choices=None,
default=None,
required=True,
help_text=None,
search_index=True,
widget=None,
validators=(),
**kwargs,
):
self._required = required
self._default = default
self.search_index = search_index
if choices is None:
# no choices specified, so pick up the choice defined at the class level
choices = self.choices
if callable(choices):
# Support of callable choices. Wrap the callable in an iterator so that we can
# handle this consistently with ordinary choice lists;
# however, the `choices` constructor kwarg as reported by deconstruct() should
# remain as the callable
choices_for_constructor = choices
choices = CallableChoiceIterator(choices)
else:
# Cast as a list
choices_for_constructor = choices = list(choices)
# keep a copy of all kwargs (including our normalised choices list) for deconstruct()
# Note: we omit the `widget` kwarg, as widgets do not provide a serialization method
# for migrations, and they are unlikely to be useful within the frozen ORM anyhow
self._constructor_kwargs = kwargs.copy()
self._constructor_kwargs["choices"] = choices_for_constructor
if required is not True:
self._constructor_kwargs["required"] = required
if help_text is not None:
self._constructor_kwargs["help_text"] = help_text
# We will need to modify the choices list to insert a blank option, if there isn't
# one already. We have to do this at render time in the case of callable choices - so rather
# than having separate code paths for static vs dynamic lists, we'll _always_ pass a callable
# to ChoiceField to perform this step at render time.
callable_choices = self._get_callable_choices(choices)
self.field = self.get_field(
choices=callable_choices,
required=required,
help_text=help_text,
validators=validators,
widget=widget,
)
super().__init__(default=default, **kwargs)
def _get_callable_choices(self, choices, blank_choice=True):
"""
Return a callable that we can pass into `forms.ChoiceField`, which will provide the
choices list with the addition of a blank choice (if blank_choice=True and one does not
already exist).
"""
def choices_callable():
# Variable choices could be an instance of CallableChoiceIterator, which may be wrapping
# something we don't want to evaluate multiple times (e.g. a database query). Cast as a
# list now to prevent it getting evaluated twice (once while searching for a blank choice,
# once while rendering the final ChoiceField).
local_choices = list(choices)
# If blank_choice=False has been specified, return the choices list as is
if not blank_choice:
return local_choices
# Else: if choices does not already contain a blank option, insert one
# (to match Django's own behaviour for modelfields:
# https://github.com/django/django/blob/1.7.5/django/db/models/fields/__init__.py#L732-744)
has_blank_choice = False
for v1, v2 in local_choices:
if isinstance(v2, (list, tuple)):
# this is a named group, and v2 is the value list
has_blank_choice = any(value in ("", None) for value, label in v2)
if has_blank_choice:
break
else:
# this is an individual choice; v1 is the value
if v1 in ("", None):
has_blank_choice = True
break
if not has_blank_choice:
return BLANK_CHOICE_DASH + local_choices
return local_choices
return choices_callable
class Meta:
# No icon specified here, because that depends on the purpose that the
# block is being used for. Feel encouraged to specify an icon in your
# descendant block type
icon = "placeholder"
class ChoiceBlock(BaseChoiceBlock):
def get_field(self, **kwargs):
return forms.ChoiceField(**kwargs)
def _get_callable_choices(self, choices, blank_choice=None):
# If we have a default choice and the field is required, we don't need to add a blank option.
if blank_choice is None:
blank_choice = not (self._default and self._required)
return super()._get_callable_choices(choices, blank_choice=blank_choice)
def deconstruct(self):
"""
Always deconstruct ChoiceBlock instances as if they were plain ChoiceBlocks with their
choice list passed in the constructor, even if they are actually subclasses. This allows
users to define subclasses of ChoiceBlock in their models.py, with specific choice lists
passed in, without references to those classes ending up frozen into migrations.
"""
return ("wagtail.blocks.ChoiceBlock", [], self._constructor_kwargs)
def get_searchable_content(self, value):
# Return the display value as the searchable value
if not self.search_index:
return []
text_value = force_str(value)
for k, v in self.field.choices:
if isinstance(v, (list, tuple)):
# This is an optgroup, so look inside the group for options
for k2, v2 in v:
if value == k2 or text_value == force_str(k2):
return [force_str(k), force_str(v2)]
else:
if value == k or text_value == force_str(k):
return [force_str(v)]
return [] # Value was not found in the list of choices
class MultipleChoiceBlock(BaseChoiceBlock):
def get_field(self, **kwargs):
return forms.MultipleChoiceField(**kwargs)
def _get_callable_choices(self, choices, blank_choice=False):
"""Override to default blank choice to False"""
return super()._get_callable_choices(choices, blank_choice=blank_choice)
def deconstruct(self):
"""
Always deconstruct MultipleChoiceBlock instances as if they were plain
MultipleChoiceBlocks with their choice list passed in the constructor,
even if they are actually subclasses. This allows users to define
subclasses of MultipleChoiceBlock in their models.py, with specific choice
lists passed in, without references to those classes ending up frozen
into migrations.
"""
return ("wagtail.blocks.MultipleChoiceBlock", [], self._constructor_kwargs)
def get_searchable_content(self, value):
# Return the display value as the searchable value
if not self.search_index:
return []
content = []
text_value = force_str(value)
for k, v in self.field.choices:
if isinstance(v, (list, tuple)):
# This is an optgroup, so look inside the group for options
for k2, v2 in v:
if value == k2 or text_value == force_str(k2):
content.append(force_str(k))
content.append(force_str(v2))
else:
if value == k or text_value == force_str(k):
content.append(force_str(v))
return content
class RichTextBlock(FieldBlock):
def __init__(
self,
required=True,
help_text=None,
editor="default",
features=None,
max_length=None,
validators=(),
search_index=True,
**kwargs,
):
if max_length is not None:
validators = list(validators) + [
RichTextMaxLengthValidator(max_length),
]
self.field_options = {
"required": required,
"help_text": help_text,
"validators": validators,
}
self.max_length = max_length
self.editor = editor
self.features = features
self.search_index = search_index
super().__init__(**kwargs)
def to_python(self, value):
# convert a source-HTML string from the JSONish representation
# to a RichText object
return RichText(value)
def get_prep_value(self, value):
# convert a RichText object back to a source-HTML string to go into
# the JSONish representation
return value.source
def normalize(self, value):
if isinstance(value, RichText):
return value
return RichText(value)
@cached_property
def field(self):
from wagtail.admin.rich_text import get_rich_text_editor_widget
return forms.CharField(
widget=get_rich_text_editor_widget(self.editor, features=self.features),
**self.field_options,
)
def value_for_form(self, value):
# Rich text editors take the source-HTML string as input (and takes care
# of expanding it for the purposes of the editor)
return value.source
def value_from_form(self, value):
# Rich text editors return a source-HTML string; convert to a RichText object
return RichText(value)
def get_searchable_content(self, value):
if not self.search_index:
return []
source = force_str(value.source)
# Strip HTML tags to prevent search backend from indexing them
return [get_text_for_indexing(source)]
def extract_references(self, value):
# Extracts any references to images/pages/embeds
yield from extract_references_from_rich_text(force_str(value.source))
class Meta:
icon = "pilcrow"
class RawHTMLBlock(FieldBlock):
def __init__(
self,
required=True,
help_text=None,
max_length=None,
min_length=None,
validators=(),
**kwargs,
):
self.field = forms.CharField(
required=required,
help_text=help_text,
max_length=max_length,
min_length=min_length,
validators=validators,
widget=forms.Textarea,
)
super().__init__(**kwargs)
def get_default(self):
return self.normalize(self.meta.default or "")
def to_python(self, value):
return mark_safe(value)
def normalize(self, value):
return mark_safe(value)
def get_prep_value(self, value):
# explicitly convert to a plain string, just in case we're using some serialisation method
# that doesn't cope with SafeString values correctly
return str(value) + ""
def value_for_form(self, value):
# need to explicitly mark as unsafe, or it'll output unescaped HTML in the textarea
return str(value) + ""
def value_from_form(self, value):
return mark_safe(value)
class Meta:
icon = "code"
class ChooserBlock(FieldBlock):
def __init__(self, required=True, help_text=None, validators=(), **kwargs):
self._required = required
self._help_text = help_text
self._validators = validators
super().__init__(**kwargs)
"""Abstract superclass for fields that implement a chooser interface (page, image, snippet etc)"""
@cached_property
def model_class(self):
return resolve_model_string(self.target_model)
@cached_property
def field(self):
return forms.ModelChoiceField(
queryset=self.model_class.objects.all(),
widget=self.widget,
required=self._required,
validators=self._validators,
help_text=self._help_text,
)
def to_python(self, value):
# the incoming serialised value should be None or an ID
if value is None:
return value
else:
try:
return self.model_class.objects.get(pk=value)
except self.model_class.DoesNotExist:
return None
def bulk_to_python(self, values):
"""Return the model instances for the given list of primary keys.
The instances must be returned in the same order as the values and keep None values.
"""
objects = self.model_class.objects.in_bulk(values)
return [
objects.get(id) for id in values
] # Keeps the ordering the same as in values.
def get_prep_value(self, value):
# the native value (a model instance or None) should serialise to a PK or None
if value is None:
return None
else:
return value.pk
def value_from_form(self, value):
# ModelChoiceField sometimes returns an ID, and sometimes an instance; we want the instance
if value is None or isinstance(value, self.model_class):
return value
else:
try:
return self.model_class.objects.get(pk=value)
except self.model_class.DoesNotExist:
return None
def get_form_state(self, value):
return self.widget.get_value_data(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.model_class):
value = value.pk
return super().clean(value)
def extract_references(self, value):
if value is not None and issubclass(self.model_class, Model):
yield self.model_class, str(value.pk), "", ""
class Meta:
# No icon specified here, because that depends on the purpose that the
# block is being used for. Feel encouraged to specify an icon in your
# descendant block type
icon = "placeholder"
class PageChooserBlock(ChooserBlock):
def __init__(
self, page_type=None, can_choose_root=False, target_model=None, **kwargs
):
# We cannot simply deprecate 'target_model' in favour of 'page_type'
# as it would force developers to update their old migrations.
# Mapping the old 'target_model' to the new 'page_type' kwarg instead.
if target_model:
page_type = target_model
if page_type:
# Convert single string/model into a list
if not isinstance(page_type, (list, tuple)):
page_type = [page_type]
else:
page_type = []
self.page_type = page_type
self.can_choose_root = can_choose_root
super().__init__(**kwargs)
@cached_property
def target_model(self):
"""
Defines the model used by the base ChooserBlock for ID <-> instance
conversions. If a single page type is specified in target_model,
we can use that to get the more specific instance "for free"; otherwise
use the generic Page model.
"""
if len(self.target_models) == 1:
return self.target_models[0]
return resolve_model_string("wagtailcore.Page")
@cached_property
def target_models(self):
target_models = []
for target_model in self.page_type:
target_models.append(resolve_model_string(target_model))
return target_models
@cached_property
def widget(self):
from wagtail.admin.widgets import AdminPageChooser
return AdminPageChooser(
target_models=self.target_models, can_choose_root=self.can_choose_root
)
def get_form_state(self, value):
value_data = self.widget.get_value_data(value)
if value_data is None:
return None
else:
return {
"id": value_data["id"],
"parentId": value_data["parent_id"],
"adminTitle": value_data["display_title"],
"editUrl": value_data["edit_url"],
}
def render_basic(self, value, context=None):
if value:
return format_html('<a href="{0}">{1}</a>', value.url, value.title)
else:
return ""
def deconstruct(self):
name, args, kwargs = super().deconstruct()
if "target_model" in kwargs or "page_type" in kwargs:
target_models = []
for target_model in self.target_models:
opts = target_model._meta
target_models.append(f"{opts.app_label}.{opts.object_name}")
kwargs.pop("target_model", None)
kwargs["page_type"] = target_models
return name, args, kwargs
class Meta:
icon = "doc-empty-inverse"
# Ensure that the blocks defined here get deconstructed as wagtailcore.blocks.FooBlock
# rather than wagtailcore.blocks.field.FooBlock
block_classes = [
FieldBlock,
CharBlock,
URLBlock,
RichTextBlock,
RawHTMLBlock,
ChooserBlock,
PageChooserBlock,
TextBlock,
BooleanBlock,
DateBlock,
TimeBlock,
DateTimeBlock,
ChoiceBlock,
MultipleChoiceBlock,
EmailBlock,
IntegerBlock,
FloatBlock,
DecimalBlock,
RegexBlock,
BlockQuoteBlock,
]
DECONSTRUCT_ALIASES = {cls: "wagtail.blocks.%s" % cls.__name__ for cls in block_classes}
__all__ = [cls.__name__ for cls in block_classes]

View File

@@ -0,0 +1,495 @@
import uuid
from collections.abc import Mapping, MutableSequence
from django import forms
from django.core.exceptions import ValidationError
from django.forms.utils import ErrorList
from django.utils.functional import cached_property
from django.utils.html import format_html, format_html_join
from django.utils.translation import gettext as _
from wagtail.admin.staticfiles import versioned_static
from wagtail.telepath import Adapter, register
from .base import (
Block,
BoundBlock,
get_error_json_data,
get_error_list_json_data,
get_help_icon,
)
__all__ = ["ListBlock", "ListBlockValidationError"]
class ListBlockValidationError(ValidationError):
def __init__(self, block_errors=None, non_block_errors=None):
# non_block_errors may be passed here as an ErrorList, a plain list (of strings or
# ValidationErrors), or None.
# Normalise it to be an ErrorList, which provides an as_data() method that consistently
# returns a flat list of ValidationError objects.
# (note: iterating over ErrorList itself appears to give a list of message strings,
# but doesn't correctly account for ValidationErrors containing multiple messages)
# (note 2: items in this list are expected to be plain ValidationError instances; there is
# no special treatment of subclasses such as StructBlockValidationError)
self.non_block_errors = ErrorList(non_block_errors)
# block_errors may be passed here as a dict whose keys are the indexes of the child blocks
# with errors, or a list (corresponding to the block value's elements, with None for child
# blocks with no errors)
# Items in this list / dict may be:
# - a ValidationError instance (potentially a subclass such as StructBlockValidationError)
# - an ErrorList containing a single ValidationError
# - a plain list containing a single ValidationError
# All representations will be normalised to a dict of ValidationError instances,
# which is also the preferred format for the original argument to be in.
# normalise to a dict
if block_errors is None:
block_errors_dict = {}
elif isinstance(block_errors, Mapping):
block_errors_dict = block_errors
elif isinstance(block_errors, list):
block_errors_dict = {
index: val for index, val in enumerate(block_errors) if val is not None
}
else:
raise ValueError(
"Expected dict or list for block_errors, got %r" % block_errors
)
# normalise items to ValidationError instances
self.block_errors = {}
for index, val in block_errors_dict.items():
if isinstance(val, ErrorList):
self.block_errors[index] = val.as_data()[0]
elif isinstance(val, list):
self.block_errors[index] = val[0]
else:
self.block_errors[index] = val
super().__init__("Validation error in ListBlock")
def as_json_data(self):
result = {}
if self.non_block_errors:
result["messages"] = get_error_list_json_data(self.non_block_errors)
if self.block_errors:
result["blockErrors"] = {
index: get_error_json_data(error)
for index, error in self.block_errors.items()
}
return result
class ListValue(MutableSequence):
"""
The native data type used by ListBlock. Behaves as a list of values, but also provides
a bound_blocks property giving access to block IDs
"""
class ListChild(BoundBlock):
# a wrapper for list values that keeps track of the associated block type and ID
def __init__(self, *args, **kwargs):
self.original_id = kwargs.pop("id", None)
self.id = self.original_id or str(uuid.uuid4())
super().__init__(*args, **kwargs)
def get_prep_value(self):
return {
"type": "item",
"value": self.block.get_prep_value(self.value),
"id": self.id,
}
def __init__(self, list_block, values=None, bound_blocks=None):
self.list_block = list_block
if bound_blocks is not None:
self.bound_blocks = bound_blocks
elif values is not None:
self.bound_blocks = [
ListValue.ListChild(self.list_block.child_block, value)
for value in values
]
else:
self.bound_blocks = []
def __getitem__(self, i):
return self.bound_blocks[i].value
def __setitem__(self, i, item):
self.bound_blocks[i] = ListValue.ListChild(self.list_block.child_block, item)
def __delitem__(self, i):
del self.bound_blocks[i]
def __len__(self):
return len(self.bound_blocks)
def insert(self, i, item):
self.bound_blocks.insert(
i, ListValue.ListChild(self.list_block.child_block, item)
)
def __repr__(self):
return f"<ListValue: {[bb.value for bb in self.bound_blocks]!r}>"
class ListBlock(Block):
def __init__(self, child_block, search_index=True, **kwargs):
super().__init__(**kwargs)
self.search_index = search_index
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
if not hasattr(self.meta, "default"):
# Default to a list consisting of one empty (i.e. default-valued) child item
self.meta.default = [self.child_block.get_default()]
# If a subclass of ListBlock overrides __init__, we cannot assume that the first argument is
# the child block, and thus we cannot rely on the conversion applied in construct_from_lookup /
# deconstruct_with_lookup to be valid. We set a flag attribute on the __init__ method so that
# we can spot this case.
__init__.has_child_block_arg = True
@classmethod
def construct_from_lookup(cls, lookup, *args, **kwargs):
if getattr(cls.__init__, "has_child_block_arg", False):
if args and isinstance(args[0], int):
child_block = lookup.get_block(args[0])
args = (child_block, *args[1:])
else:
child_block_kwarg = kwargs.get("child_block")
if isinstance(child_block_kwarg, int):
child_block = lookup.get_block(child_block_kwarg)
kwargs["child_block"] = child_block
return cls(*args, **kwargs)
def value_from_datadict(self, data, files, prefix):
count = int(data["%s-count" % prefix])
child_blocks_with_indexes = []
for i in range(0, count):
if data["%s-%d-deleted" % (prefix, i)]:
continue
child_blocks_with_indexes.append(
(
int(data["%s-%d-order" % (prefix, i)]),
ListValue.ListChild(
self.child_block,
self.child_block.value_from_datadict(
data, files, "%s-%d-value" % (prefix, i)
),
id=data.get("%s-%d-id" % (prefix, i)),
),
)
)
child_blocks_with_indexes.sort()
return ListValue(self, bound_blocks=[b for (i, b) in child_blocks_with_indexes])
def value_omitted_from_data(self, data, files, prefix):
return ("%s-count" % prefix) not in data
def clean(self, value):
# value is expected to be a ListValue, but if it's been assigned through external code it might
# be a plain list; normalise it to a ListValue
value = self.normalize(value)
result = []
block_errors = {}
non_block_errors = ErrorList()
for index, bound_block in enumerate(value.bound_blocks):
try:
result.append(
ListValue.ListChild(
self.child_block,
self.child_block.clean(bound_block.value),
id=bound_block.id,
)
)
except ValidationError as e:
block_errors[index] = e
if self.meta.min_num is not None and self.meta.min_num > len(value):
non_block_errors.append(
ValidationError(
_("The minimum number of items is %(min_num)d")
% {"min_num": self.meta.min_num}
)
)
if self.meta.max_num is not None and self.meta.max_num < len(value):
non_block_errors.append(
ValidationError(
_("The maximum number of items is %(max_num)d")
% {"max_num": self.meta.max_num}
)
)
if block_errors or non_block_errors:
raise ListBlockValidationError(
block_errors=block_errors, non_block_errors=non_block_errors
)
return ListValue(self, bound_blocks=result)
def normalize(self, value):
if isinstance(value, ListValue):
return value
elif isinstance(value, list):
return ListValue(
self, values=[self.child_block.normalize(x) for x in value]
)
else:
raise TypeError(
f"Cannot handle {value!r} (type {type(value)!r}) as a value of a ListBlock"
)
def empty_value(self):
return ListValue(self, values=[])
def _item_is_in_block_format(self, item):
# check a list item retrieved from the database JSON representation to see whether it follows
# the new format (https://github.com/wagtail/rfcs/blob/main/text/065-listblock.md) for a list item
return (
isinstance(item, dict)
and "id" in item
and "value" in item
and item.get("type") == "item"
)
def to_python(self, value):
# 'value' is a list of child block values; use bulk_to_python to convert them all in one go
# get a list of the child block values; this will be the 'value' item of the dict if the list item
# is in the new block format, or the list item itself if in the old format
raw_values = [
item["value"] if self._item_is_in_block_format(item) else item
for item in value
]
converted_values = self.child_block.bulk_to_python(raw_values)
bound_blocks = []
for i, item in enumerate(value):
if self._item_is_in_block_format(item):
list_item_id = item["id"]
else:
list_item_id = None
bound_blocks.append(
ListValue.ListChild(
self.child_block, converted_values[i], id=list_item_id
)
)
return ListValue(self, bound_blocks=bound_blocks)
def bulk_to_python(self, values):
# 'values' is a list of lists of child block values; concatenate them into one list so that
# we can make a single call to child_block.bulk_to_python
lengths = []
raw_values = []
for list_stream in values:
lengths.append(len(list_stream))
for list_child in list_stream:
if self._item_is_in_block_format(list_child):
raw_values.append(list_child["value"])
else:
raw_values.append(list_child)
converted_values = self.child_block.bulk_to_python(raw_values)
# split converted_values back into sub-lists of the original lengths
result = []
offset = 0
values = list(values)
for i, sublist_len in enumerate(lengths):
bound_blocks = []
for j in range(sublist_len):
if self._item_is_in_block_format(values[i][j]):
list_item_id = values[i][j]["id"]
else:
list_item_id = None
bound_blocks.append(
ListValue.ListChild(
self.child_block, converted_values[offset + j], id=list_item_id
)
)
result.append(ListValue(self, bound_blocks=bound_blocks))
offset += sublist_len
return result
def get_prep_value(self, value):
# value is expected to be a ListValue, but if it's been assigned through external code it might
# be a plain list; normalise it to a ListValue
if not isinstance(value, ListValue):
value = ListValue(self, values=value)
prep_value = []
for item in value.bound_blocks:
# Convert the native value back into raw JSONish data
if not item.id:
item.id = str(uuid.uuid4())
prep_value.append(item.get_prep_value())
return prep_value
def get_form_state(self, value):
# value is expected to be a ListValue, but if it's been assigned through external code it might
# be a plain list; normalise it to a ListValue
if not isinstance(value, ListValue):
value = ListValue(self, values=value)
return [
{
"value": self.child_block.get_form_state(block.value),
"id": block.id,
}
for block in value.bound_blocks
]
def get_api_representation(self, value, context=None):
# recursively call get_api_representation on children and return as a list
return [
self.child_block.get_api_representation(item, context=context)
for item in value
]
def render_basic(self, value, context=None):
children = format_html_join(
"\n",
"<li>{0}</li>",
[
(self.child_block.render(child_value, context=context),)
for child_value in value
],
)
return format_html("<ul>{0}</ul>", children)
def get_searchable_content(self, value):
if not self.search_index:
return []
content = []
for child_value in value:
content.extend(self.child_block.get_searchable_content(child_value))
return content
def extract_references(self, value):
for child in value.bound_blocks:
for (
model,
object_id,
model_path,
content_path,
) in child.block.extract_references(child.value):
model_path = f"item.{model_path}" if model_path else "item"
content_path = (
f"{child.id}.{content_path}" if content_path else child.id
)
yield model, object_id, model_path, content_path
def get_block_by_content_path(self, value, path_elements):
"""
Given a list of elements from a content path, retrieve the block at that path
as a BoundBlock object, or None if the path does not correspond to a valid block.
"""
if path_elements:
id, *remaining_elements = path_elements
for child in value.bound_blocks:
if child.id == id:
return child.block.get_block_by_content_path(
child.value, remaining_elements
)
else:
# an empty path refers to the list as a whole
return self.bind(value)
def check(self, **kwargs):
errors = super().check(**kwargs)
errors.extend(self.child_block.check(**kwargs))
return errors
def deconstruct_with_lookup(self, lookup):
path, args, kwargs = super().deconstruct_with_lookup(lookup)
if getattr(self.__init__, "has_child_block_arg", False):
if args and isinstance(args[0], Block):
block_id = lookup.add_block(args[0])
args = (block_id, *args[1:])
else:
child_block = kwargs.get("child_block")
if isinstance(child_block, Block):
block_id = lookup.add_block(child_block)
kwargs["child_block"] = block_id
return path, args, kwargs
class Meta:
# No icon specified here, because that depends on the purpose that the
# block is being used for. Feel encouraged to specify an icon in your
# descendant block type
icon = "placeholder"
form_classname = None
min_num = None
max_num = None
collapsed = False
MUTABLE_META_ATTRIBUTES = ["min_num", "max_num"]
class ListBlockAdapter(Adapter):
js_constructor = "wagtail.blocks.ListBlock"
def js_args(self, block):
meta = {
"label": block.label,
"icon": block.meta.icon,
"classname": block.meta.form_classname,
"collapsed": block.meta.collapsed,
"strings": {
"MOVE_UP": _("Move up"),
"MOVE_DOWN": _("Move down"),
"DUPLICATE": _("Duplicate"),
"DELETE": _("Delete"),
"ADD": _("Add"),
},
}
help_text = getattr(block.meta, "help_text", None)
if help_text:
meta["helpText"] = help_text
meta["helpIcon"] = get_help_icon()
if block.meta.min_num is not None:
meta["minNum"] = block.meta.min_num
if block.meta.max_num is not None:
meta["maxNum"] = block.meta.max_num
return [
block.name,
block.child_block,
block.child_block.get_form_state(block.child_block.get_default()),
meta,
]
@cached_property
def media(self):
return forms.Media(
js=[
versioned_static("wagtailadmin/js/telepath/blocks.js"),
]
)
register(ListBlockAdapter(), ListBlock)
DECONSTRUCT_ALIASES = {
ListBlock: "wagtail.blocks.ListBlock",
}

View File

@@ -0,0 +1,352 @@
import json
import logging
from collections import OrderedDict
from django.db.models import JSONField, F, Q, Subquery, OuterRef
from django.db.models.functions import Cast
from django.db.migrations import RunPython
from django.utils.functional import cached_property
from wagtail.blocks import StreamValue
from wagtail.blocks.migrations import utils
logger = logging.getLogger(__name__)
class MigrateStreamData(RunPython):
"""Subclass of RunPython for streamfield data migration operations"""
def __init__(
self,
app_name,
model_name,
field_name,
operations_and_block_paths,
revisions_from=None,
chunk_size=1024,
**kwargs,
):
"""MigrateStreamData constructor
Args:
app_name (str): Name of the app.
model_name (str): Name of the model.
field_name (str): Name of the streamfield.
operations_and_block_paths (:obj:`list` of :obj:`tuple` of (:obj:`operation`, :obj:`str`)):
List of operations and corresponding block paths to apply.
revisions_from (:obj:`datetime`, optional): Only revisions created from this date
onwards will be updated. Passing `None` updates all revisions. Defaults to `None`.
Note that live and latest revisions will be updated regardless of what value this
takes.
chunk_size (:obj:`int`, optional): chunk size for queryset.iterator and bulk_update.
Defaults to 1024.
**kwargs: atomic, elidable, hints for superclass RunPython can be given
Example:
Renaming a block named `field1` to `block1`::
MigrateStreamData(
app_name="blog",
model_name="BlogPage",
field_name="content",
operations_and_block_paths=[
(RenameStreamChildrenOperation(old_name="field1", new_name="block1"), ""),
],
revisions_from=datetime.date(2022, 7, 25)
),
"""
self.app_name = app_name
self.model_name = model_name
self.field_name = field_name
self.operations_and_block_paths = operations_and_block_paths
self.revisions_from = revisions_from
self.chunk_size = chunk_size
# TODO add reverse code when needed, will probably need another input (reversible?)
# super class kwargs - atomic,elidable,hints
super().__init__(
code=self.migrate_stream_data_forward,
reverse_code=lambda *args: None,
**kwargs,
)
def deconstruct(self):
_, args, kwargs = super().deconstruct()
kwargs["app_name"] = self.app_name
kwargs["model_name"] = self.model_name
kwargs["field_name"] = self.field_name
kwargs["operations_and_block_paths"] = self.operations_and_block_paths
kwargs["revisions_from"] = self.revisions_from
kwargs["chunk_size"] = self.chunk_size
return (self.__class__.__qualname__, args, kwargs)
@property
def migration_name_fragment(self):
# We are using an OrderedDict here to essentially get the functionality of an ordered set
# so that names generated will be consistent.
fragments = OrderedDict(
(op.operation_name_fragment, None)
for op, _ in self.operations_and_block_paths
)
return "_".join(fragments.keys())
def migrate_stream_data_forward(self, apps, schema_editor):
model = apps.get_model(self.app_name, self.model_name)
# Here we can't directly check the wagtail version, rather we need to check the wagtail
# version at the project state when the migration is being applied
try:
apps.get_model("wagtailcore", "Revision")
revision_query_maker = DefaultRevisionQueryMaker(
apps, model, self.revisions_from
)
except LookupError:
revision_query_maker = Wagtail3RevisionQueryMaker(
apps, model, self.revisions_from
)
model_queryset = model.objects.annotate(
raw_content=Cast(F(self.field_name), JSONField())
).all()
updated_model_instances_buffer = []
for instance in model_queryset.iterator(chunk_size=self.chunk_size):
if instance.raw_content is None:
continue
revision_query_maker.append_instance_data_for_revision_query(instance)
raw_data = instance.raw_content
for operation, block_path_str in self.operations_and_block_paths:
try:
raw_data = utils.apply_changes_to_raw_data(
raw_data=raw_data,
block_path_str=block_path_str,
operation=operation,
streamfield=getattr(model, self.field_name),
)
# - TODO add a return value to util to know if changes were made
# - TODO save changed only
except utils.InvalidBlockDefError as e:
raise utils.InvalidBlockDefError(instance=instance) from e
stream_block = getattr(instance, self.field_name).stream_block
setattr(
instance,
self.field_name,
StreamValue(stream_block, raw_data, is_lazy=True),
)
updated_model_instances_buffer.append(instance)
if len(updated_model_instances_buffer) == self.chunk_size:
model.objects.bulk_update(
updated_model_instances_buffer, [self.field_name]
)
updated_model_instances_buffer = []
if len(updated_model_instances_buffer) > 0:
# For any remaining chunks
model.objects.bulk_update(updated_model_instances_buffer, [self.field_name])
# For models without revisions
if not revision_query_maker.has_revisions:
return
revision_queryset = revision_query_maker.get_revision_queryset()
updated_revisions_buffer = []
for revision in revision_queryset.iterator(chunk_size=self.chunk_size):
raw_data = json.loads(revision.content[self.field_name])
for operation, block_path_str in self.operations_and_block_paths:
try:
raw_data = utils.apply_changes_to_raw_data(
raw_data=raw_data,
block_path_str=block_path_str,
operation=operation,
streamfield=getattr(model, self.field_name),
)
except utils.InvalidBlockDefError as e:
if not revision_query_maker.get_is_live_or_latest_revision(
revision
):
logger.exception(
utils.InvalidBlockDefError(
revision=revision, instance=instance
)
)
continue
else:
raise utils.InvalidBlockDefError(
revision=revision, instance=instance
) from e
# - TODO add a return value to util to know if changes were made
# - TODO save changed only
revision.content[self.field_name] = json.dumps(raw_data)
updated_revisions_buffer.append(revision)
if len(updated_revisions_buffer) == self.chunk_size:
revision_query_maker.bulk_update(updated_revisions_buffer)
updated_revisions_buffer = []
if len(updated_revisions_buffer) > 0:
revision_query_maker.bulk_update(updated_revisions_buffer)
class AbstractRevisionQueryMaker:
"""Helper class for making the revision query needed for the data migration"""
def __init__(self, apps, model, revisions_from):
self.apps = apps
self.model = model
self.revisions_from = revisions_from
self.RevisionModel = self.get_revision_model()
self.has_revisions = self.get_has_revisions()
if self.has_revisions:
# latest or live revision ids may be available directly from the instance. In that case
# we can keep track of them here.
self.instance_field_revision_ids = set()
def get_revision_model(self):
raise NotImplementedError
def get_has_revisions(self):
raise NotImplementedError
def append_instance_data_for_revision_query(self, instance):
raise NotImplementedError
def _make_revision_query(self):
raise NotImplementedError
def get_revision_queryset(self):
revision_query = self._make_revision_query()
return self.RevisionModel.objects.filter(revision_query)
def bulk_update(self, data):
self.RevisionModel.objects.bulk_update(data, ["content"])
def get_is_live_or_latest_revision(self, revision):
raise NotImplementedError
class Wagtail3RevisionQueryMaker(AbstractRevisionQueryMaker):
"""Revision Query maker to support Wagtail 3"""
def __init__(self, apps, model, revisions_from):
self.page_ids = []
super().__init__(apps, model, revisions_from)
def get_revision_model(self):
return self.apps.get_model("wagtailcore", "PageRevision")
def get_has_revisions(self):
return issubclass(self.model, self.apps.get_model("wagtailcore", "Page"))
def append_instance_data_for_revision_query(self, instance):
if self.has_revisions:
self.page_ids.append(instance.id)
self.instance_field_revision_ids.add(instance.live_revision_id)
def _make_revision_query(self):
if self.revisions_from is not None:
# All revisions created after the given date.
revision_query = Q(
created_at__gte=self.revisions_from,
page_id__in=self.page_ids,
)
# All live revisions.
revision_query = revision_query | Q(id__in=self.instance_field_revision_ids)
# All latest revisions. For each revision, we check if it is the revision with the
# last `created_at` from all revisions with its `page_id`.
revision_query = revision_query | Q(
id__in=Subquery(
self.RevisionModel.objects.filter(page_id=OuterRef("page_id"))
.order_by("-created_at", "-id")
.values_list("id", flat=True)[:1]
),
page_id__in=self.page_ids,
)
return revision_query
# otherwise query all revisions for the page
else:
return Q(page_id__in=self.page_ids)
def get_is_live_or_latest_revision(self, revision):
if revision.id in self.instance_field_revision_ids:
return True
return revision.id in self._latest_revision_ids
@cached_property
def _latest_revision_ids(self):
return self.RevisionModel.objects.filter(
id__in=Subquery(
self.RevisionModel.objects.filter(page_id=OuterRef("page_id"))
.order_by("-created_at", "-id")
.values_list("id", flat=True)[:1]
),
page_id__in=self.page_ids,
).values_list("id", flat=True)
class DefaultRevisionQueryMaker(AbstractRevisionQueryMaker):
"""Revision Query Maker for Wagtail 4+"""
def __init__(self, apps, model, revisions_from):
self.has_live_revisions = False
self.has_latest_revisions = False
super().__init__(apps, model, revisions_from)
def get_revision_model(self):
return self.apps.get_model("wagtailcore", "Revision")
def get_has_revisions(self):
# We check if the models have a field `latest_revision` and make sure it points to the
# Revision model. This relation is there on models with `RevisionMixin`.
self.has_latest_revisions = (
hasattr(self.model, "latest_revision")
and self.model.latest_revision.field.remote_field.model
is self.RevisionModel
)
# Again, check for `live_revision`. This relation is there on models with `DraftStateMixin`.
self.has_live_revisions = (
hasattr(self.model, "live_revision")
and self.model.live_revision.field.remote_field.model is self.RevisionModel
)
return self.has_latest_revisions or self.has_live_revisions
def append_instance_data_for_revision_query(self, instance):
if self.has_revisions:
# From wagtail 4 onwards, there can be non page models which may have live or latest
# revisions, but not necessarily having both at the same time.
if self.has_latest_revisions:
self.instance_field_revision_ids.add(instance.latest_revision_id)
if self.has_live_revisions:
self.instance_field_revision_ids.add(instance.live_revision_id)
def _make_revision_query(self):
ContentType = self.apps.get_model("contenttypes", "ContentType")
contenttype_id = ContentType.objects.get_for_model(self.model).id
# if revisions_from is given, then query only the revisions created after that
# datetime (and the latest and live revisions if they are not after revisions_from)
if self.revisions_from is not None:
# All revisions created after the given date.
revision_query = Q(
created_at__gte=self.revisions_from,
content_type_id=contenttype_id,
)
# All live and latest revisions
revision_query = revision_query | Q(id__in=self.instance_field_revision_ids)
return revision_query
# otherwise query all revisions for the model
else:
return Q(content_type_id=contenttype_id)
def get_is_live_or_latest_revision(self, revision):
return revision.id in self.instance_field_revision_ids

View File

@@ -0,0 +1,332 @@
from abc import ABC, abstractmethod
from wagtail.blocks.migrations.utils import formatted_list_child_generator
from django.utils.deconstruct import deconstructible
class BaseBlockOperation(ABC):
def __init__(self):
pass
@abstractmethod
def apply(self, block_value):
pass
@property
@abstractmethod
def operation_name_fragment(self):
pass
@deconstructible
class RenameStreamChildrenOperation(BaseBlockOperation):
"""Renames all StreamBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be renamed, not the block being renamed.
Attributes:
old_name (str): name of the child block type to be renamed
new_name (str): new name to rename to
"""
def __init__(self, old_name, new_name):
super().__init__()
self.old_name = old_name
self.new_name = new_name
def apply(self, block_value):
mapped_block_value = []
for child_block in block_value:
if child_block["type"] == self.old_name:
mapped_block_value.append({**child_block, "type": self.new_name})
else:
mapped_block_value.append(child_block)
return mapped_block_value
@property
def operation_name_fragment(self):
return f"rename_{self.old_name}_to_{self.new_name}"
@deconstructible
class RenameStructChildrenOperation(BaseBlockOperation):
"""Renames all StructBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StructBlock
which contains the blocks to be renamed, not the block being renamed.
Attributes:
old_name (str): name of the child block type to be renamed
new_name (str): new name to rename to
"""
def __init__(self, old_name, new_name):
super().__init__()
self.old_name = old_name
self.new_name = new_name
def apply(self, block_value):
mapped_block_value = {}
for child_key, child_value in block_value.items():
if child_key == self.old_name:
mapped_block_value[self.new_name] = child_value
else:
mapped_block_value[child_key] = child_value
return mapped_block_value
@property
def operation_name_fragment(self):
return f"rename_{self.old_name}_to_{self.new_name}"
@deconstructible
class RemoveStreamChildrenOperation(BaseBlockOperation):
"""Removes all StreamBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be removed, not the block being removed.
Attributes:
name (str): name of the child block type to be removed
"""
def __init__(self, name):
super().__init__()
self.name = name
def apply(self, block_value):
return [
child_block
for child_block in block_value
if child_block["type"] != self.name
]
@property
def operation_name_fragment(self):
return f"remove_{self.name}"
@deconstructible
class RemoveStructChildrenOperation(BaseBlockOperation):
"""Removes all StructBlock children of the given type
Note:
The `block_path_str` when using this operation should point to the parent StructBlock
which contains the blocks to be removed, not the block being removed.
Attributes:
name (str): name of the child block type to be removed
"""
def __init__(self, name):
super().__init__()
self.name = name
def apply(self, block_value):
return {
child_key: child_value
for child_key, child_value in block_value.items()
if child_key != self.name
}
@property
def operation_name_fragment(self):
return f"remove_{self.name}"
class StreamChildrenToListBlockOperation(BaseBlockOperation):
"""Combines StreamBlock children of the given type into a new ListBlock
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be combined, not the child block itself.
Attributes:
block_name (str): name of the child block type to be combined
list_block_name (str): name of the new ListBlock type
"""
def __init__(self, block_name, list_block_name):
super().__init__()
self.block_name = block_name
self.list_block_name = list_block_name
self.temp_blocks = []
def apply(self, block_value):
mapped_block_value = []
for child_block in block_value:
if child_block["type"] == self.block_name:
self.temp_blocks.append(child_block)
else:
mapped_block_value.append(child_block)
self.map_temp_blocks_to_list_items()
if self.temp_blocks:
new_list_block = {"type": self.list_block_name, "value": self.temp_blocks}
mapped_block_value.append(new_list_block)
return mapped_block_value
def map_temp_blocks_to_list_items(self):
new_temp_blocks = []
for block in self.temp_blocks:
new_temp_blocks.append({**block, "type": "item"})
self.temp_blocks = new_temp_blocks
@property
def operation_name_fragment(self):
return f"{self.block_name}_to_list_block_{self.list_block_name}"
class StreamChildrenToStreamBlockOperation(BaseBlockOperation):
"""Combines StreamBlock children of the given types into a new StreamBlock
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be combined, not the child block itself.
Attributes:
block_names (:obj:`list` of :obj:`str`): names of the child block types to be combined
stream_block_name (str): name of the new StreamBlock type
"""
def __init__(self, block_names, stream_block_name):
super().__init__()
self.block_names = block_names
self.stream_block_name = stream_block_name
def apply(self, block_value):
mapped_block_value = []
stream_value = []
for child_block in block_value:
if child_block["type"] in self.block_names:
stream_value.append(child_block)
else:
mapped_block_value.append(child_block)
if stream_value:
new_stream_block = {"type": self.stream_block_name, "value": stream_value}
mapped_block_value.append(new_stream_block)
return mapped_block_value
@property
def operation_name_fragment(self):
return "{}_to_stream_block".format("_".join(self.block_names))
class AlterBlockValueOperation(BaseBlockOperation):
"""Alters the value of each block to the given value
Attributes:
new_value : new value to change to
"""
def __init__(self, new_value):
super().__init__()
self.new_value = new_value
def apply(self, block_value):
return self.new_value
@property
def operation_name_fragment(self):
return "alter_block_value"
class StreamChildrenToStructBlockOperation(BaseBlockOperation):
"""Move each StreamBlock child of the given type inside a new StructBlock
A new StructBlock will be created as a child of the parent StreamBlock for each child block of
the given type, and then that child block will be moved from the parent StreamBlocks children
inside the new StructBlock as a child of that StructBlock.
Example:
Consider the following StreamField definition::
mystream = StreamField([("char1", CharBlock()) ...], ...)
Then the stream data would look like the following::
[
...
{ "type": "char1", "value": "Value1", ... },
{ "type": "char1", "value": "Value2", ... },
...
]
And if we define the operation like this::
StreamChildrenToStructBlockOperation("char1", "struct1")
Our altered stream data would look like this::
[
...
{ "type": "struct1", "value": { "char1": "Value1" } },
{ "type": "struct1", "value": { "char1": "Value2" } },
...
]
Note:
The `block_path_str` when using this operation should point to the parent StreamBlock
which contains the blocks to be combined, not the child block itself.
Note:
Block ids are not preserved here since the new blocks are structurally different than the
previous blocks.
Attributes:
block_names (str): names of the child block types to be combined
struct_block_name (str): name of the new StructBlock type
"""
def __init__(self, block_name, struct_block_name):
super().__init__()
self.block_name = block_name
self.struct_block_name = struct_block_name
def apply(self, block_value):
mapped_block_value = []
for child_block in block_value:
if child_block["type"] == self.block_name:
mapped_block_value.append(
{
**child_block,
"type": self.struct_block_name,
"value": {self.block_name: child_block["value"]},
}
)
else:
mapped_block_value.append(child_block)
return mapped_block_value
@property
def operation_name_fragment(self):
return f"{self.block_name}_to_struct_block_{self.struct_block_name}"
class ListChildrenToStructBlockOperation(BaseBlockOperation):
def __init__(self, block_name):
super().__init__()
self.block_name = block_name
def apply(self, block_value):
mapped_block_value = []
# In case there is data from the old list format (wagtail < 2.16), we use the generator
# to convert them into the new list format
for child_block in formatted_list_child_generator(block_value):
mapped_block_value.append(
{**child_block, "value": {self.block_name: child_block["value"]}}
)
return mapped_block_value
@property
def operation_name_fragment(self):
return f"list_block_items_to_{self.block_name}"

View File

@@ -0,0 +1,291 @@
from wagtail.blocks import ListBlock, StreamBlock, StructBlock
class InvalidBlockDefError(Exception):
"""Exception for invalid block definitions"""
def __init__(self, *args, instance=None, revision=None, **kwargs):
# in the case of a revision pass both instance and revision
self.instance = instance
self.revision = revision
super().__init__(*args, **kwargs)
def __str__(self):
message = ""
if self.instance is not None:
message += "Invalid block def in {} object ({})".format(
self.instance.__class__.__name__, self.instance.id
)
if self.revision is not None:
message += " for revision id ({}) created at {}".format(
self.revision.id,
self.revision.created_at,
)
if self.args:
message += "\n"
message += super().__str__()
return message
def should_alter_block(block_name, block_path):
# If the block is not at the start of `block_path`, then neither it nor its children are
# blocks that we need to alter.
return block_name == block_path[0]
def map_block_value(block_value, block_def, block_path, operation, **kwargs):
"""
Maps the value of a block.
Args:
block_value:
The value of the block. This would be a list or dict of children for structural blocks.
block_def:
The definition of the block.
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
operation:
An Operation class instance (extends `BaseBlockOperation`), which has an `apply` method
for mapping values.
Returns:
mapped_value:
"""
# If the `block_path` length is 0, that means we've reached the end of the block path, that
# is, the block where we need to apply the operation. Note that we are asking the user to
# pass "item" as part of the block path for list children, so it won't give rise to any
# problems here.
if len(block_path) == 0:
return operation.apply(block_value)
# Depending on whether the block is a ListBlock, StructBlock or StreamBlock we call a
# different function to alter its children.
if isinstance(block_def, StreamBlock):
return map_stream_block_value(
block_value,
operation=operation,
block_def=block_def,
block_path=block_path,
**kwargs,
)
elif isinstance(block_def, ListBlock):
return map_list_block_value(
block_value,
operation=operation,
block_def=block_def,
block_path=block_path,
**kwargs,
)
elif isinstance(block_def, StructBlock):
return map_struct_block_value(
block_value,
operation=operation,
block_def=block_def,
block_path=block_path,
**kwargs,
)
else:
raise ValueError(f"Unexpected Structural Block: {block_value}")
def map_stream_block_value(stream_block_value, block_def, block_path, **kwargs):
"""
Maps each child block in a StreamBlock value.
Args:
stream_block_value:
The value of the StreamBlock, a list of child blocks
block_def:
The definition of the StreamBlock
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
Returns
mapped_value:
The value of the StreamBlock after mapping all the children.
"""
mapped_value = []
for child_block in stream_block_value:
if not should_alter_block(child_block["type"], block_path):
mapped_value.append(child_block)
else:
try:
child_block_def = block_def.child_blocks[child_block["type"]]
except KeyError:
raise InvalidBlockDefError(
"No current block def named {}".format(child_block["type"])
)
mapped_child_value = map_block_value(
child_block["value"],
block_def=child_block_def,
block_path=block_path[1:],
**kwargs,
)
mapped_value.append({**child_block, "value": mapped_child_value})
return mapped_value
def map_struct_block_value(struct_block_value, block_def, block_path, **kwargs):
"""
Maps each child block in a StructBlock value.
Args:
stream_block_value:
The value of the StructBlock, a dict of child blocks
block_def:
The definition of the StructBlock
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
Returns
mapped_value:
The value of the StructBlock after mapping all the children.
"""
mapped_value = {}
for key, child_value in struct_block_value.items():
if not should_alter_block(key, block_path):
mapped_value[key] = child_value
else:
try:
child_block_def = block_def.child_blocks[key]
except KeyError:
raise InvalidBlockDefError(f"No current block def named {key}")
altered_child_value = map_block_value(
child_value,
block_def=child_block_def,
block_path=block_path[1:],
**kwargs,
)
mapped_value[key] = altered_child_value
return mapped_value
def map_list_block_value(list_block_value, block_def, block_path, **kwargs):
"""
Maps each child block in a ListBlock value.
Args:
stream_block_value:
The value of the ListBlock, a list of child blocks
block_def:
The definition of the ListBlock
block_path:
A '.' separated list of names of the blocks from the current block (not included) to
the nested block of which the value will be passed to the operation.
Returns
mapped_value:
The value of the ListBlock after mapping all the children.
"""
mapped_value = []
# In case data is in old list format
for child_block in formatted_list_child_generator(list_block_value):
mapped_child_value = map_block_value(
child_block["value"],
block_def=block_def.child_block,
block_path=block_path[1:],
**kwargs,
)
mapped_value.append({**child_block, "value": mapped_child_value})
return mapped_value
def formatted_list_child_generator(list_block_value):
is_old_format = False
if not isinstance(list_block_value[0], dict):
is_old_format = True
elif "type" not in list_block_value[0] or list_block_value[0]["type"] != "item":
is_old_format = True
for child in list_block_value:
if not is_old_format:
yield child
else:
yield {"type": "item", "value": child}
def apply_changes_to_raw_data(
raw_data, block_path_str, operation, streamfield, **kwargs
):
"""
Applies changes to raw stream data
Args:
raw_data:
The current stream data (a list of top level blocks)
block_path_str:
A '.' separated list of names of the blocks from the top level block to the nested
block of which the value will be passed to the operation.
eg:- 'simplestream.struct1' would point to,
[..., { type: simplestream, value: [..., { type: struct1, value: {...} }] }]
NOTE: If we're directly applying changes on the top level stream block, then this will
be "".
NOTE: When the path contains a ListBlock child, 'item' must be added to the block as
the name of said child.
eg:- 'list1.item.stream1' where the list child is a StructBlock would point to,
[
...,
{
type: list1,
value: [
{
type: item,
value: { ..., stream1: [...] }
},
...
]
}
]
operation:
A subclass of `operations.BaseBlockOperation`. It will have the `apply` method
for applying changes to the matching block values.
streamfield:
The streamfield for which data is being migrated. This is used to get the definitions
of the blocks.
Returns:
altered_raw_data:
"""
if block_path_str == "":
# If block_path_str is "", we're directly applying the operation on the top level
# streamblock.
block_path = []
else:
block_path = block_path_str.split(".")
block_def = streamfield.field.stream_block
altered_raw_data = map_block_value(
raw_data,
block_def=block_def,
block_path=block_path,
operation=operation,
**kwargs,
)
return altered_raw_data

View File

@@ -0,0 +1,70 @@
from django import forms
from django.utils.functional import cached_property
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from wagtail.admin.staticfiles import versioned_static
from wagtail.telepath import Adapter, register
from .base import Block
__all__ = ["StaticBlock"]
class StaticBlock(Block):
"""
A block that just 'exists' and has no fields.
"""
def get_admin_text(self):
if self.meta.admin_text is None:
if self.label:
return _("%(label)s: this block has no options.") % {
"label": self.label
}
else:
return _("This block has no options.")
return self.meta.admin_text
def value_from_datadict(self, data, files, prefix):
return None
def normalize(self, value):
return None
class Meta:
admin_text = None
default = None
class StaticBlockAdapter(Adapter):
js_constructor = "wagtail.blocks.StaticBlock"
def js_args(self, block):
admin_text = block.get_admin_text()
if isinstance(admin_text, SafeString):
text_or_html = "html"
else:
text_or_html = "text"
return [
block.name,
{
text_or_html: admin_text,
"icon": block.meta.icon,
"label": block.label,
},
]
@cached_property
def media(self):
return forms.Media(
js=[
versioned_static("wagtailadmin/js/telepath/blocks.js"),
]
)
register(StaticBlockAdapter(), StaticBlock)

View File

@@ -0,0 +1,868 @@
import itertools
import json
import uuid
from collections import OrderedDict, defaultdict
from collections.abc import Mapping, MutableSequence
from pickle import PickleError
from django import forms
from django.core.exceptions import ValidationError
from django.db.models.fields import _load_field
from django.forms.utils import ErrorList
from django.utils.functional import cached_property
from django.utils.html import format_html_join
from django.utils.translation import gettext as _
from wagtail.admin.staticfiles import versioned_static
from wagtail.telepath import Adapter, register
from .base import (
Block,
BoundBlock,
DeclarativeSubBlocksMetaclass,
get_error_json_data,
get_error_list_json_data,
get_help_icon,
)
__all__ = [
"BaseStreamBlock",
"StreamBlock",
"StreamValue",
"StreamBlockValidationError",
]
class StreamBlockValidationError(ValidationError):
def __init__(self, block_errors=None, non_block_errors=None):
# non_block_errors may be passed here as an ErrorList, a plain list (of strings or
# ValidationErrors), or None.
# Normalise it to be an ErrorList, which provides an as_data() method that consistently
# returns a flat list of ValidationError objects.
self.non_block_errors = ErrorList(non_block_errors)
# block_errors may be passed here as None, or a dict keyed by the indexes of the child blocks
# with errors.
# Items in this list / dict may be:
# - a ValidationError instance (potentially a subclass such as StructBlockValidationError)
# - an ErrorList containing a single ValidationError
# - a plain list containing a single ValidationError
# All representations will be normalised to a dict of ValidationError instances,
# which is also the preferred format for the original argument to be in.
self.block_errors = {}
if block_errors is None:
pass
else:
for index, val in block_errors.items():
if isinstance(val, ErrorList):
self.block_errors[index] = val.as_data()[0]
elif isinstance(val, list):
self.block_errors[index] = val[0]
else:
self.block_errors[index] = val
super().__init__("Validation error in StreamBlock")
def as_json_data(self):
result = {}
if self.non_block_errors:
result["messages"] = get_error_list_json_data(self.non_block_errors)
if self.block_errors:
result["blockErrors"] = {
index: get_error_json_data(error)
for (index, error) in self.block_errors.items()
}
return result
class BaseStreamBlock(Block):
def __init__(self, local_blocks=None, search_index=True, **kwargs):
self._constructor_kwargs = kwargs
self.search_index = search_index
super().__init__(**kwargs)
# create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
self.child_blocks = self.base_blocks.copy()
if local_blocks:
for name, block in local_blocks:
block.set_name(name)
self.child_blocks[name] = block
@classmethod
def construct_from_lookup(cls, lookup, child_blocks, **kwargs):
if child_blocks:
child_blocks = [
(name, lookup.get_block(index)) for name, index in child_blocks
]
return cls(child_blocks, **kwargs)
def empty_value(self, raw_text=None):
return StreamValue(self, [], raw_text=raw_text)
def sorted_child_blocks(self):
"""Child blocks, sorted in to their groups."""
return sorted(
self.child_blocks.values(), key=lambda child_block: child_block.meta.group
)
def grouped_child_blocks(self):
"""
The available child block types of this stream block, organised into groups according to
their meta.group attribute.
Returned as an iterable of (group_name, list_of_blocks) tuples
"""
return itertools.groupby(
self.sorted_child_blocks(), key=lambda child_block: child_block.meta.group
)
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)
),
data.get("%s-%d-id" % (prefix, i)),
)
)
values_with_indexes.sort()
return StreamValue(
self,
[
(child_block_type_name, value, block_id)
for (
index,
child_block_type_name,
value,
block_id,
) in values_with_indexes
],
)
def value_omitted_from_data(self, data, files, prefix):
return ("%s-count" % prefix) not in data
@property
def required(self):
return self.meta.required
def clean(self, value):
cleaned_data = []
errors = {}
non_block_errors = ErrorList()
for i, child in enumerate(value): # child is a StreamChild instance
try:
cleaned_data.append(
(child.block.name, child.block.clean(child.value), child.id)
)
except ValidationError as e:
errors[i] = e
if self.meta.min_num is not None and self.meta.min_num > len(value):
non_block_errors.append(
ValidationError(
_("The minimum number of items is %(min_num)d")
% {"min_num": self.meta.min_num}
)
)
elif self.required and len(value) == 0:
non_block_errors.append(ValidationError(_("This field is required.")))
if self.meta.max_num is not None and self.meta.max_num < len(value):
non_block_errors.append(
ValidationError(
_("The maximum number of items is %(max_num)d")
% {"max_num": self.meta.max_num}
)
)
if self.meta.block_counts:
block_counts = defaultdict(int)
for item in value:
block_counts[item.block_type] += 1
for block_name, min_max in self.meta.block_counts.items():
block = self.child_blocks[block_name]
max_num = min_max.get("max_num", None)
min_num = min_max.get("min_num", None)
block_count = block_counts[block_name]
if min_num is not None and min_num > block_count:
non_block_errors.append(
ValidationError(
"{}: {}".format(
block.label,
_("The minimum number of items is %(min_num)d")
% {"min_num": min_num},
)
)
)
if max_num is not None and max_num < block_count:
non_block_errors.append(
ValidationError(
"{}: {}".format(
block.label,
_("The maximum number of items is %(max_num)d")
% {"max_num": max_num},
)
)
)
if errors or non_block_errors:
# The message here is arbitrary - outputting error messages is delegated to the child blocks,
# which only involves the 'params' list
raise StreamBlockValidationError(
block_errors=errors, non_block_errors=non_block_errors
)
return StreamValue(self, cleaned_data)
def to_python(self, value):
if isinstance(value, StreamValue):
return value
elif isinstance(value, str) and value:
try:
value = json.loads(value)
except ValueError:
# value is not valid JSON; most likely, this field was previously a
# rich text field before being migrated to StreamField, and the data
# was left intact in the migration. Return an empty stream instead
# (but keep the raw text available as an attribute, so that it can be
# used to migrate that data to StreamField)
return self.empty_value(raw_text=value)
if not value:
return self.empty_value()
# ensure value is a list and not some other kind of iterable
value = list(value)
if isinstance(value[0], dict):
# value is in JSONish representation - a dict with 'type' and 'value' keys.
# 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,
)
else:
# See if it looks like the standard non-smart representation of a
# StreamField value: a list of (block_name, value) tuples
try:
[None for (x, y) in value]
except (TypeError, ValueError) as exc:
# Give up trying to make sense of the value
raise TypeError(
f"Cannot handle {value!r} (type {type(value)!r}) as a value of a StreamBlock"
) from exc
# Test succeeded, so return as a StreamValue-ified version of that value
return StreamValue(
self,
[
(k, self.child_blocks[k].normalize(v))
for k, v in value
if k in self.child_blocks
],
)
def bulk_to_python(self, values):
# 'values' is a list of streams, each stream being a list of dicts with 'type', 'value' and
# optionally 'id'.
# We will iterate over these streams, constructing:
# 1) a set of per-child-block lists ('child_inputs'), to be sent to each child block's
# bulk_to_python method in turn (giving us 'child_outputs')
# 2) a 'block map' of each stream, telling us the type and id of each block and the index we
# need to look up in the corresponding child_outputs list to obtain its final value
child_inputs = defaultdict(list)
block_maps = []
for stream in values:
block_map = []
for block_dict in stream:
block_type = block_dict["type"]
if block_type not in self.child_blocks:
# skip any blocks with an unrecognised type
continue
child_input_list = child_inputs[block_type]
child_index = len(child_input_list)
child_input_list.append(block_dict["value"])
block_map.append((block_type, block_dict.get("id"), child_index))
block_maps.append(block_map)
# run each list in child_inputs through the relevant block's bulk_to_python
# to obtain child_outputs
child_outputs = {
block_type: self.child_blocks[block_type].bulk_to_python(child_input_list)
for block_type, child_input_list in child_inputs.items()
}
# for each stream, go through the block map, picking out the appropriately-indexed
# value from the relevant list in child_outputs
return [
StreamValue(
self,
[
(block_type, child_outputs[block_type][child_index], id)
for block_type, id, child_index in block_map
],
is_lazy=False,
)
for block_map in block_maps
]
def get_prep_value(self, value):
if not value:
# Falsy values (including None, empty string, empty list, and
# empty StreamValue) become an empty stream
return []
else:
# value is a StreamValue - delegate to its get_prep_value() method
# (which has special-case handling for lazy StreamValues to avoid useless
# round-trips to the full data representation and back)
return value.get_prep_value()
def normalize(self, value):
return self.to_python(value)
def get_form_state(self, value):
if not value:
return []
else:
return [
{
"type": child.block.name,
"value": child.block.get_form_state(child.value),
"id": child.id,
}
for child in value
]
def get_api_representation(self, value, context=None):
if value is None:
# treat None as identical to an empty stream
return []
return [
{
"type": child.block.name,
"value": child.block.get_api_representation(
child.value, context=context
),
"id": child.id,
}
for child in value # child is a StreamChild instance
]
def render_basic(self, value, context=None):
return format_html_join(
"\n",
'<div class="block-{1}">{0}</div>',
[(child.render(context=context), child.block_type) for child in value],
)
def get_searchable_content(self, value):
if not self.search_index:
return []
content = []
for child in value:
content.extend(child.block.get_searchable_content(child.value))
return content
def extract_references(self, value):
for child in value:
for (
model,
object_id,
model_path,
content_path,
) in child.block.extract_references(child.value):
model_path = (
f"{child.block_type}.{model_path}"
if model_path
else child.block_type
)
content_path = (
f"{child.id}.{content_path}" if content_path else child.id
)
yield model, object_id, model_path, content_path
def get_block_by_content_path(self, value, path_elements):
"""
Given a list of elements from a content path, retrieve the block at that path
as a BoundBlock object, or None if the path does not correspond to a valid block.
"""
if path_elements:
id, *remaining_elements = path_elements
for child in value:
if child.id == id:
return child.block.get_block_by_content_path(
child.value, remaining_elements
)
else:
# an empty path refers to the stream as a whole
return self.bind(value)
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.blocks.StreamBlock"
args = [list(self.child_blocks.items())]
kwargs = self._constructor_kwargs
return (path, args, kwargs)
def deconstruct_with_lookup(self, lookup):
path = "wagtail.blocks.StreamBlock"
args = [
[
(name, lookup.add_block(block))
for name, block in self.child_blocks.items()
]
]
kwargs = self._constructor_kwargs
return (path, args, kwargs)
def check(self, **kwargs):
errors = super().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 Meta:
# No icon specified here, because that depends on the purpose that the
# block is being used for. Feel encouraged to specify an icon in your
# descendant block type
icon = "placeholder"
default = []
required = True
form_classname = None
min_num = None
max_num = None
block_counts = {}
collapsed = False
MUTABLE_META_ATTRIBUTES = [
"required",
"min_num",
"max_num",
"block_counts",
"collapsed",
]
class StreamBlock(BaseStreamBlock, metaclass=DeclarativeSubBlocksMetaclass):
pass
class StreamValue(MutableSequence):
"""
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).
"""
class StreamChild(BoundBlock):
"""
Iterating over (or indexing into) a StreamValue returns instances of StreamChild.
These are wrappers for the individual data items in the stream, extending BoundBlock
(which keeps track of the data item's corresponding Block definition object, and provides
the `render` method to render itself with a template) with an `id` property (a UUID
assigned to the item - this is managed by the enclosing StreamBlock and is not a property
of blocks in general) and a `block_type` property.
"""
def __init__(self, *args, **kwargs):
self.id = kwargs.pop("id")
super().__init__(*args, **kwargs)
@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 get_prep_value(self):
return {
"type": self.block_type,
"value": self.block.get_prep_value(self.value),
"id": self.id,
}
def _as_tuple(self):
if self.id:
return (self.block.name, self.value, self.id)
else:
return (self.block.name, self.value)
class RawDataView(MutableSequence):
"""
Internal helper class to present the stream data in raw JSONish format. For backwards
compatibility with old code that manipulated StreamValue.stream_data, this is considered
mutable to some extent, with the proviso that once the BoundBlock representation has been
accessed, any changes to fields within raw data will not propagate back to the BoundBlock
and will not be saved back when calling get_prep_value.
"""
def __init__(self, stream_value):
self.stream_value = stream_value
def __getitem__(self, i):
item = self.stream_value._raw_data[i]
if item is None:
# reconstruct raw data from the bound block
item = self.stream_value._bound_blocks[i].get_prep_value()
self.stream_value._raw_data[i] = item
return item
def __len__(self):
return len(self.stream_value._raw_data)
def __setitem__(self, i, item):
self.stream_value._raw_data[i] = item
# clear the cached bound_block for this item
self.stream_value._bound_blocks[i] = None
def __delitem__(self, i):
# same as deletion on the stream itself - delete both the raw and bound_block data
del self.stream_value[i]
def insert(self, i, item):
self.stream_value._raw_data.insert(i, item)
self.stream_value._bound_blocks.insert(i, None)
def __repr__(self):
return repr(list(self))
class BlockNameLookup(Mapping):
"""
Dict-like object returned from `blocks_by_name`, for looking up a stream's blocks by name.
Uses lazy evaluation on access, so that we're not redundantly constructing StreamChild
instances for blocks of different names.
"""
def __init__(self, stream_value, find_all=True):
self.stream_value = stream_value
self.block_names = stream_value.stream_block.child_blocks.keys()
self.find_all = (
find_all # whether to return all results rather than just the first
)
def __getitem__(self, block_name):
result = [] if self.find_all else None
if block_name not in self.block_names:
# skip the search and return an empty result
return result
for i in range(len(self.stream_value)):
# Skip over blocks that have not yet been instantiated from _raw_data and are of
# different names to the one we're looking for
if (
self.stream_value._bound_blocks[i] is None
and self.stream_value._raw_data[i]["type"] != block_name
):
continue
block = self.stream_value[i]
if block.block_type == block_name:
if self.find_all:
result.append(block)
else:
return block
return result
def __iter__(self):
yield from self.block_names
def __len__(self):
return len(self.block_names)
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)
or (type_name, value, id) 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.stream_block = (
stream_block # the StreamBlock object that handles this value
)
self.is_lazy = is_lazy
self.raw_text = raw_text
if is_lazy:
# store raw stream data in _raw_data; on retrieval it will be converted to a native
# value (via block.to_python) and wrapped as a StreamValue, and cached in _bound_blocks.
self._raw_data = stream_data
self._bound_blocks = [None] * len(stream_data)
else:
# store native stream data in _bound_blocks; on serialization it will be converted to
# a JSON-ish representation via block.get_prep_value.
self._raw_data = [None] * len(stream_data)
self._bound_blocks = [
self._construct_stream_child(item) for item in stream_data
]
def _construct_stream_child(self, item):
"""
Create a StreamChild instance from a (type, value, id) or (type, value) tuple,
or return item if it's already a StreamChild
"""
if isinstance(item, StreamValue.StreamChild):
return item
try:
type_name, value, block_id = item
except ValueError:
type_name, value = item
block_id = None
block_def = self.stream_block.child_blocks[type_name]
return StreamValue.StreamChild(block_def, value, id=block_id)
def __getitem__(self, i):
if isinstance(i, slice):
start, stop, step = i.indices(len(self._bound_blocks))
return [self[j] for j in range(start, stop, step)]
if self._bound_blocks[i] is None:
raw_value = self._raw_data[i]
self._prefetch_blocks(raw_value["type"])
return self._bound_blocks[i]
def __setitem__(self, i, item):
self._bound_blocks[i] = self._construct_stream_child(item)
def __delitem__(self, i):
del self._bound_blocks[i]
del self._raw_data[i]
def insert(self, i, item):
self._bound_blocks.insert(i, self._construct_stream_child(item))
self._raw_data.insert(i, None)
@cached_property
def raw_data(self):
return StreamValue.RawDataView(self)
def _prefetch_blocks(self, type_name):
"""
Populate _bound_blocks with all items in this stream of type `type_name` that exist in
_raw_data but do not already exist in _bound_blocks.
Fetching is done via the block's bulk_to_python method, so that database lookups are
batched into a single query where possible.
"""
child_block = self.stream_block.child_blocks[type_name]
# create a mapping of all the child blocks matching the given block type,
# mapping (index within the stream) => (raw block value)
raw_values = OrderedDict(
(i, raw_item["value"])
for i, raw_item in enumerate(self._raw_data)
if raw_item["type"] == type_name and self._bound_blocks[i] is None
)
# pass the raw block values to bulk_to_python as a list
converted_values = child_block.bulk_to_python(raw_values.values())
# reunite the converted values with their stream indexes, along with the block ID
# if one exists
for i, value in zip(raw_values.keys(), converted_values):
self._bound_blocks[i] = StreamValue.StreamChild(
child_block, value, id=self._raw_data[i].get("id")
)
def get_prep_value(self):
prep_value = []
for i, item in enumerate(self._bound_blocks):
if item:
# Convert the native value back into raw JSONish data
if not item.id:
item.id = str(uuid.uuid4())
prep_value.append(item.get_prep_value())
else:
# item has not been converted to a BoundBlock, so its _raw_data entry is
# still usable (but ensure it has an ID before returning it)
raw_item = self._raw_data[i]
if not raw_item.get("id"):
raw_item["id"] = str(uuid.uuid4())
prep_value.append(raw_item)
return prep_value
def blocks_by_name(self, block_name=None):
lookup = StreamValue.BlockNameLookup(self, find_all=True)
if block_name:
return lookup[block_name]
else:
return lookup
def first_block_by_name(self, block_name=None):
lookup = StreamValue.BlockNameLookup(self, find_all=False)
if block_name:
return lookup[block_name]
else:
return lookup
def __eq__(self, other):
if not isinstance(other, StreamValue) or len(other) != len(self):
return False
# scan both lists for non-matching items
for i in range(0, len(self)):
if self._bound_blocks[i] is None and other._bound_blocks[i] is None:
# compare raw values as a shortcut to save the conversion step
if self._raw_data[i] != other._raw_data[i]:
return False
else:
this_item = self[i]
other_item = other[i]
if (
this_item.block_type != other_item.block_type
or this_item.id != other_item.id
or this_item.value != other_item.value
):
return False
return True
def __len__(self):
return len(self._bound_blocks)
def __repr__(self):
return f"<{type(self).__name__} {list(self)!r}>"
def render_as_block(self, context=None):
return self.stream_block.render(self, context=context)
def __html__(self):
return self.stream_block.render(self)
def __str__(self):
return self.__html__()
@staticmethod
def _deserialize_pickle_value(app_label, model_name, field_name, field_value):
"""Returns StreamValue from pickled data"""
field = _load_field(app_label, model_name, field_name)
return field.to_python(field_value)
def __reduce__(self):
try:
stream_field = self._stream_field
except AttributeError:
raise PickleError(
"StreamValue can only be pickled if it is associated with a StreamField"
)
return (
self._deserialize_pickle_value,
(
stream_field.model._meta.app_label,
stream_field.model._meta.object_name,
stream_field.name,
self.get_prep_value(),
),
)
class StreamBlockAdapter(Adapter):
js_constructor = "wagtail.blocks.StreamBlock"
def js_args(self, block):
meta = {
"label": block.label,
"required": block.required,
"icon": block.meta.icon,
"classname": block.meta.form_classname,
"maxNum": block.meta.max_num,
"minNum": block.meta.min_num,
"blockCounts": block.meta.block_counts,
"collapsed": block.meta.collapsed,
"strings": {
"MOVE_UP": _("Move up"),
"MOVE_DOWN": _("Move down"),
"DUPLICATE": _("Duplicate"),
"DELETE": _("Delete"),
"ADD": _("Add"),
},
}
help_text = getattr(block.meta, "help_text", None)
if help_text:
meta["helpText"] = help_text
meta["helpIcon"] = get_help_icon()
return [
block.name,
block.grouped_child_blocks(),
{
name: child_block.get_form_state(child_block.get_default())
for name, child_block in block.child_blocks.items()
},
meta,
]
@cached_property
def media(self):
return forms.Media(
js=[
versioned_static("wagtailadmin/js/telepath/blocks.js"),
]
)
register(StreamBlockAdapter(), StreamBlock)

View File

@@ -0,0 +1,435 @@
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.functional import cached_property
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from wagtail.admin.staticfiles import versioned_static
from wagtail.telepath import Adapter, register
from .base import (
Block,
BoundBlock,
DeclarativeSubBlocksMetaclass,
get_error_json_data,
get_error_list_json_data,
get_help_icon,
)
__all__ = [
"BaseStructBlock",
"StructBlock",
"StructValue",
"StructBlockValidationError",
]
class StructBlockValidationError(ValidationError):
def __init__(self, block_errors=None, non_block_errors=None):
# non_block_errors may be passed here as an ErrorList, a plain list (of strings or
# ValidationErrors), or None.
# Normalise it to be an ErrorList, which provides an as_data() method that consistently
# returns a flat list of ValidationError objects.
self.non_block_errors = ErrorList(non_block_errors)
# block_errors may be passed here as None, or a dict keyed by the names of the child blocks
# with errors.
# Items in this list / dict may be:
# - a ValidationError instance (potentially a subclass such as StructBlockValidationError)
# - an ErrorList containing a single ValidationError
# - a plain list containing a single ValidationError
# All representations will be normalised to a dict of ValidationError instances,
# which is also the preferred format for the original argument to be in.
self.block_errors = {}
if block_errors is None:
pass
else:
for name, val in block_errors.items():
if isinstance(val, ErrorList):
self.block_errors[name] = val.as_data()[0]
elif isinstance(val, list):
self.block_errors[name] = val[0]
else:
self.block_errors[name] = val
super().__init__("Validation error in StructBlock")
def as_json_data(self):
result = {}
if self.non_block_errors:
result["messages"] = get_error_list_json_data(self.non_block_errors)
if self.block_errors:
result["blockErrors"] = {
name: get_error_json_data(error)
for (name, error) in self.block_errors.items()
}
return result
class StructValue(collections.OrderedDict):
"""A class that generates a StructBlock value from provided sub-blocks"""
def __init__(self, block, *args):
super().__init__(*args)
self.block = block
def __html__(self):
return self.block.render(self)
def render_as_block(self, context=None):
return self.block.render(self, context=context)
@cached_property
def bound_blocks(self):
return collections.OrderedDict(
[
(name, block.bind(self.get(name)))
for name, block in self.block.child_blocks.items()
]
)
def __reduce__(self):
return (self.__class__, (self.block,), None, None, iter(self.items()))
class PlaceholderBoundBlock(BoundBlock):
"""
Provides a render_form method that outputs a block placeholder, for use in custom form_templates
"""
def render_form(self):
return format_html('<div data-structblock-child="{}"></div>', self.block.name)
class BaseStructBlock(Block):
def __init__(self, local_blocks=None, search_index=True, **kwargs):
self._constructor_kwargs = kwargs
self.search_index = search_index
super().__init__(**kwargs)
# create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
self.child_blocks = self.base_blocks.copy()
if local_blocks:
for name, block in local_blocks:
block.set_name(name)
self.child_blocks[name] = block
@classmethod
def construct_from_lookup(cls, lookup, child_blocks, **kwargs):
if child_blocks:
child_blocks = [
(name, lookup.get_block(index)) for name, index in child_blocks
]
return cls(child_blocks, **kwargs)
def get_default(self):
"""
Any default value passed in the constructor or self.meta is going to be a dict
rather than a StructValue; for consistency, we need to convert it to a StructValue
for StructBlock to work with
"""
return self.normalize(
{
name: self.meta.default[name]
if name in self.meta.default
else block.get_default()
for name, block in self.child_blocks.items()
}
)
def value_from_datadict(self, data, files, prefix):
return self._to_struct_value(
[
(
name,
block.value_from_datadict(data, files, f"{prefix}-{name}"),
)
for name, block in self.child_blocks.items()
]
)
def value_omitted_from_data(self, data, files, prefix):
return all(
block.value_omitted_from_data(data, files, f"{prefix}-{name}")
for name, block in self.child_blocks.items()
)
def clean(self, value):
result = [] # build up a list of (name, value) tuples to be passed to the StructValue constructor
errors = {}
for name, val in value.items():
try:
result.append((name, self.child_blocks[name].clean(val)))
except ValidationError as e:
errors[name] = e
if errors:
raise StructBlockValidationError(errors)
return self._to_struct_value(result)
def to_python(self, value):
"""Recursively call to_python on children and return as a StructValue"""
return self._to_struct_value(
[
(
name,
(
child_block.to_python(value[name])
if name in value
else child_block.get_default()
),
# NB the result of get_default is NOT passed through to_python, as it's expected
# to be in the block's native type already
)
for name, child_block in self.child_blocks.items()
]
)
def bulk_to_python(self, values):
# values is a list of dicts; split this into a series of per-subfield lists so that we can
# call bulk_to_python on each subfield
values_by_subfield = {}
for name, child_block in self.child_blocks.items():
# We need to keep track of which dicts actually have an item for this field, as missing
# values will be populated with child_block.get_default(); this is expected to be a
# value in the block's native type, and should therefore not undergo conversion via
# bulk_to_python.
indexes = []
raw_values = []
for i, val in enumerate(values):
if name in val:
indexes.append(i)
raw_values.append(val[name])
converted_values = child_block.bulk_to_python(raw_values)
# create a mapping from original index to converted value
converted_values_by_index = dict(zip(indexes, converted_values))
# now loop over all list indexes, falling back on the default for any indexes not in
# the mapping, to arrive at the final list for this subfield
values_by_subfield[name] = []
for i in range(0, len(values)):
try:
converted_value = converted_values_by_index[i]
except KeyError:
converted_value = child_block.get_default()
values_by_subfield[name].append(converted_value)
# now form the final list of StructValues, with each one constructed by taking the
# appropriately-indexed item from all of the per-subfield lists
return [
self._to_struct_value(
{name: values_by_subfield[name][i] for name in self.child_blocks.keys()}
)
for i in range(0, len(values))
]
def _to_struct_value(self, block_items):
"""Return a Structvalue representation of the sub-blocks in this block"""
return self.meta.value_class(self, block_items)
def get_prep_value(self, value):
"""Recursively call get_prep_value on children and return as a plain dict"""
return {
name: self.child_blocks[name].get_prep_value(val)
for name, val in value.items()
}
def normalize(self, value):
if isinstance(value, self.meta.value_class):
return value
return self._to_struct_value(
{k: self.child_blocks[k].normalize(v) for k, v in value.items()}
)
def get_form_state(self, value):
return {
name: self.child_blocks[name].get_form_state(val)
for name, val in value.items()
}
def get_api_representation(self, value, context=None):
"""Recursively call get_api_representation on children and return as a plain dict"""
return {
name: self.child_blocks[name].get_api_representation(val, context=context)
for name, val in value.items()
}
def get_searchable_content(self, value):
if not self.search_index:
return []
content = []
for name, block in self.child_blocks.items():
content.extend(
block.get_searchable_content(value.get(name, block.get_default()))
)
return content
def extract_references(self, value):
for name, block in self.child_blocks.items():
for model, object_id, model_path, content_path in block.extract_references(
value.get(name, block.get_default())
):
model_path = f"{name}.{model_path}" if model_path else name
content_path = f"{name}.{content_path}" if content_path else name
yield model, object_id, model_path, content_path
def get_block_by_content_path(self, value, path_elements):
"""
Given a list of elements from a content path, retrieve the block at that path
as a BoundBlock object, or None if the path does not correspond to a valid block.
"""
if path_elements:
name, *remaining_elements = path_elements
try:
child_block = self.child_blocks[name]
except KeyError:
return None
child_value = value.get(name, child_block.get_default())
return child_block.get_block_by_content_path(
child_value, remaining_elements
)
else:
# an empty path refers to the struct as a whole
return self.bind(value)
def deconstruct(self):
"""
Always deconstruct StructBlock instances as if they were plain StructBlocks with all of the
field definitions passed to the constructor - even if in reality this is a subclass of StructBlock
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.blocks.StructBlock"
args = [list(self.child_blocks.items())]
kwargs = self._constructor_kwargs
return (path, args, kwargs)
def deconstruct_with_lookup(self, lookup):
path = "wagtail.blocks.StructBlock"
args = [
[
(name, lookup.add_block(block))
for name, block in self.child_blocks.items()
]
]
kwargs = self._constructor_kwargs
return (path, args, kwargs)
def check(self, **kwargs):
errors = super().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
def render_basic(self, value, context=None):
return format_html(
"<dl>\n{}\n</dl>",
format_html_join("\n", " <dt>{}</dt>\n <dd>{}</dd>", value.items()),
)
def render_form_template(self):
# Support for custom form_template options in meta. Originally form_template would have been
# invoked once for each occurrence of this block in the stream data, but this rendering now
# happens client-side, so we need to turn the Django template into one that can be used by
# the client-side code. This is done by rendering it up-front with placeholder objects as
# child blocks - these return <div data-structblock-child="first-name"></div> from their
# render_form_method.
# The change to client-side rendering means that the `value` and `errors` arguments on
# `get_form_context` no longer receive real data; these are passed the block's default value
# and None respectively.
context = self.get_form_context(
self.get_default(), prefix="__PREFIX__", errors=None
)
return mark_safe(render_to_string(self.meta.form_template, context))
def get_form_context(self, value, prefix="", errors=None):
return {
"children": collections.OrderedDict(
[
(
name,
PlaceholderBoundBlock(
block, value.get(name), prefix=f"{prefix}-{name}"
),
)
for name, block in self.child_blocks.items()
]
),
"help_text": getattr(self.meta, "help_text", None),
"classname": self.meta.form_classname,
"block_definition": self,
"prefix": prefix,
}
class Meta:
default = {}
form_classname = "struct-block"
form_template = None
value_class = StructValue
label_format = None
# No icon specified here, because that depends on the purpose that the
# block is being used for. Feel encouraged to specify an icon in your
# descendant block type
icon = "placeholder"
class StructBlock(BaseStructBlock, metaclass=DeclarativeSubBlocksMetaclass):
pass
class StructBlockAdapter(Adapter):
js_constructor = "wagtail.blocks.StructBlock"
def js_args(self, block):
meta = {
"label": block.label,
"required": block.required,
"icon": block.meta.icon,
"classname": block.meta.form_classname,
}
help_text = getattr(block.meta, "help_text", None)
if help_text:
meta["helpText"] = help_text
meta["helpIcon"] = get_help_icon()
if block.meta.form_template:
meta["formTemplate"] = block.render_form_template()
if block.meta.label_format:
meta["labelFormat"] = block.meta.label_format
return [
block.name,
block.child_blocks.values(),
meta,
]
@cached_property
def media(self):
return forms.Media(
js=[
versioned_static("wagtailadmin/js/telepath/blocks.js"),
]
)
register(StructBlockAdapter(), StructBlock)