import copy 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.compat import URLField 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)) ) def get_description(self): return super().get_description() or self.field.help_text or "" 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, "description": block.get_description(), "required": block.required, "icon": block.meta.icon, "blockDefId": block.definition_prefix, "isPreviewable": block.is_previewable, "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("
{0}
", 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 = 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. If the same ID appears multiple times, a distinct object instance is created for each one. """ objects = self.model_class.objects.in_bulk(values) seen_ids = set() result = [] for id in values: obj = objects.get(id) if obj is not None and id in seen_ids: # this object is already in the result list, so we need to make a copy obj = copy.copy(obj) result.append(obj) seen_ids.add(id) return result 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('{1}', 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]