Source code for form.fields

import inspect
import phonenumbers
import sedate

from cssutils.css import CSSStyleSheet  # type:ignore[import-untyped]
from itertools import zip_longest
from email_validator import validate_email, EmailNotValidError
from markupsafe import escape, Markup
from wtforms.fields.simple import URLField

from onegov.core.html import sanitize_html
from onegov.core.utils import binary_to_dictionary
from onegov.core.utils import dictionary_to_binary
from onegov.file.utils import as_fileintent
from onegov.file.utils import IMAGE_MIME_TYPES_AND_SVG
from onegov.form import log, _
from onegov.form.utils import path_to_filename
from onegov.form.validators import ValidPhoneNumber
from onegov.form.widgets import ChosenSelectWidget
from onegov.form.widgets import HoneyPotWidget
from onegov.form.widgets import IconWidget
from onegov.form.widgets import MultiCheckboxWidget
from onegov.form.widgets import OrderedMultiCheckboxWidget
from onegov.form.widgets import PanelWidget
from onegov.form.widgets import PreviewWidget
from onegov.form.widgets import TagsWidget
from onegov.form.widgets import TextAreaWithTextModules
from onegov.form.widgets import TypeAheadInput
from onegov.form.widgets import UploadWidget
from onegov.form.widgets import UploadMultipleWidget
from webcolors import name_to_hex, normalize_hex
from werkzeug.datastructures import MultiDict
from wtforms.fields import DateTimeLocalField as DateTimeLocalFieldBase
from wtforms.fields import Field
from wtforms.fields import FieldList
from wtforms.fields import FileField
from wtforms.fields import SelectField
from wtforms.fields import SelectMultipleField
from wtforms.fields import StringField
from wtforms.fields import TelField
from wtforms.fields import TextAreaField
from wtforms.fields import TimeField as DefaultTimeField
from wtforms.utils import unset_value
from wtforms.validators import DataRequired
from wtforms.validators import InputRequired
from wtforms.validators import ValidationError
from wtforms.widgets import CheckboxInput, ColorInput


from typing import Any, IO, Literal, TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Callable, Iterator, Sequence
    from datetime import datetime
    from onegov.core.types import FileDict as StrictFileDict
    from onegov.file import File
    from onegov.form import Form
    from onegov.form.types import (
        FormT, Filter, PricingRules, RawFormValue, Validators, Widget)
    from typing import TypedDict, Self
    from webob.request import _FieldStorageWithFile
    from wtforms.form import BaseForm
    from wtforms.meta import (
        _MultiDictLikeWithGetlist, _SupportsGettextAndNgettext, DefaultMeta)

[docs] class FileDict(TypedDict, total=False):
[docs] data: str
[docs] filename: str | None
[docs] mimetype: str
[docs] size: int
# this is only generic at type checking time class UploadMultipleBase(FieldList['UploadField']): pass else: UploadMultipleBase = FieldList
[docs] FIELDS_NO_RENDERED_PLACEHOLDER = ( 'MultiCheckboxField', 'RadioField', 'OrderedMultiCheckboxField', 'UploadField', 'ChosenSelectField', 'ChosenSelectMultipleField', 'PreviewField', 'PanelField', 'UploadFileWithORMSupport' )
[docs] class TimeField(DefaultTimeField): """ Fixes the case for MS Edge Browser that returns the 'valuelist' as [08:00:000] instead of [08:00:00]. This is only the case of the time is set with the js popup, not when switching the time e.g. with the arrow keys on the form. """
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: if not valuelist: return valuelist = [t[:8] for t in valuelist] # type:ignore[index] super().process_formdata(valuelist)
[docs] class TranslatedSelectField(SelectField): """ A select field which translates the option labels. """
[docs] def iter_choices( self ) -> 'Iterator[tuple[Any, str, bool, dict[str, Any]]]': for choice in super().iter_choices(): result = list(choice) result[1] = self.meta.request.translate(result[1]) yield tuple(result)
[docs] class MultiCheckboxField(SelectMultipleField):
[docs] widget = MultiCheckboxWidget()
[docs] option_widget = CheckboxInput()
[docs] contains_labels = True
[docs] class OrderedMultiCheckboxField(MultiCheckboxField):
[docs] widget = OrderedMultiCheckboxWidget()
[docs] class UploadField(FileField): """ A custom file field that turns the uploaded file into a compressed base64 string together with the filename, size and mimetype. """
[docs] widget = UploadWidget()
[docs] action: Literal['keep', 'replace', 'delete']
[docs] file: IO[bytes] | None
[docs] filename: str | None
if TYPE_CHECKING: def __init__( self, label: str | None = None, validators: 'Validators[FormT, Self] | None' = None, filters: 'Sequence[Filter]' = (), description: str = '', id: str | None = None, default: 'Sequence[StrictFileDict]' = (), widget: 'Widget[Self] | None' = None, render_kw: dict[str, Any] | None = None, name: str | None = None, _form: 'BaseForm | None' = None, _prefix: str = '', _translations: '_SupportsGettextAndNgettext | None' = None, _meta: 'DefaultMeta | None' = None, # onegov specific kwargs that get popped off *, fieldset: str | None = None, depends_on: Sequence[Any] | None = None, pricing: PricingRules | None = None, ): ... # this is not quite accurate, since it is either a dictionary with all # the keys or none of the keys, which would make type narrowing easier # unfortunately a union of two TypedDict will narrow to the TypedDict # with the fewest shared keys, which would always be an empty dictionary @property
[docs] def data(self) -> 'StrictFileDict | FileDict | None': frame = inspect.currentframe() assert frame is not None and frame.f_back is not None caller = frame.f_back.f_locals.get('self') # give the required validators the idea that the data is there # when the action was to keep the current file - an evil approach if isinstance(caller, (DataRequired, InputRequired)): truthy = ( getattr(self, '_data', None) or getattr(self, 'action', None) == 'keep' ) return truthy # type:ignore[return-value] return getattr(self, '_data', None)
@data.setter def data(self, value: 'FileDict') -> None: self._data = value @property
[docs] def is_image(self) -> bool: if not self.data: return False return self.data.get('mimetype') in IMAGE_MIME_TYPES_AND_SVG
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: if not valuelist: self.data = {} return fieldstorage: RawFormValue action: RawFormValue if len(valuelist) == 4: # resend_upload action = valuelist[0] fieldstorage = valuelist[1] self.data = binary_to_dictionary( dictionary_to_binary({'data': str(valuelist[3])}), str(valuelist[2]) ) elif len(valuelist) == 2: # force_simple action, fieldstorage = valuelist else: # default action = 'replace' fieldstorage = valuelist[0] if action == 'replace': self.action = 'replace' self.data = self.process_fieldstorage(fieldstorage) elif action == 'delete': self.action = 'delete' self.data = {} elif action == 'keep': self.action = 'keep' else: raise NotImplementedError()
[docs] def process_fieldstorage( self, fs: 'RawFormValue' ) -> 'StrictFileDict | FileDict': self.file = getattr(fs, 'file', getattr(fs, 'stream', None)) self.filename = path_to_filename(getattr(fs, 'filename', None)) if not self.file: return {} self.file.seek(0) try: return binary_to_dictionary(self.file.read(), self.filename) finally: self.file.seek(0)
[docs] class UploadFileWithORMSupport(UploadField): """ Extends the upload field with onegov.file support. """
[docs] file_class: type['File']
def __init__(self, *args: Any, **kwargs: Any): self.file_class = kwargs.pop('file_class') super().__init__(*args, **kwargs)
[docs] def create(self) -> 'File | None': if not getattr(self, 'file', None): return None assert self.file is not None self.file.filename = self.filename # type:ignore[attr-defined] self.file.seek(0) return self.file_class( # type:ignore[misc] name=self.filename, reference=as_fileintent(self.file, self.filename) )
[docs] def populate_obj(self, obj: object, name: str) -> None: if not getattr(self, 'action', None): return if self.action == 'keep': pass elif self.action == 'delete': setattr(obj, name, None) elif self.action == 'replace': setattr(obj, name, self.create()) else: raise NotImplementedError(f'Unknown action: {self.action}')
[docs] def process_data(self, value: 'File | None') -> None: if value: try: size = value.reference.file.content_length except OSError: # if the file doesn't exist on disk we try to fail # silently for now size = -1 self.data = { 'filename': value.name, 'size': size, 'mimetype': value.reference.content_type } else: super().process_data(value)
[docs] class UploadMultipleField(UploadMultipleBase, FileField): """ A custom file field that turns the uploaded files into a list of compressed base64 strings together with the filename, size and mimetype. This acts both like a single file field with multiple and like a list of :class:`onegov.form.fields.UploadFile` for uploaded files. This way we get the best of both worlds. """
[docs] widget = UploadMultipleWidget()
[docs] raw_data: list['RawFormValue']
if TYPE_CHECKING:
[docs] _separator: str
def _add_entry(self, d: _MultiDictLikeWithGetlist, /) -> UploadField: ...
[docs] upload_field_class: type[UploadField] = UploadField
[docs] upload_widget: 'Widget[UploadField]' = UploadWidget()
def __init__( self, label: str | None = None, validators: 'Validators[FormT, UploadField] | None' = None, filters: 'Sequence[Filter]' = (), description: str = '', id: str | None = None, default: 'Sequence[FileDict]' = (), widget: 'Widget[Self] | None' = None, render_kw: dict[str, Any] | None = None, name: str | None = None, upload_widget: 'Widget[UploadField] | None' = None, _form: 'BaseForm | None' = None, _prefix: str = '', _translations: '_SupportsGettextAndNgettext | None' = None, _meta: 'DefaultMeta | None' = None, # onegov specific kwargs that get popped off *, fieldset: str | None = None, depends_on: 'Sequence[Any] | None' = None, pricing: 'PricingRules | None' = None, # if we change the upload_field_class there may be additional # parameters that are allowed so we pass them through **extra_arguments: Any ): if upload_widget is None: upload_widget = self.upload_widget # a lot of the arguments we just pass through to the subfield unbound_field = self.upload_field_class( validators=validators, # type:ignore[arg-type] filters=filters, description=description, widget=upload_widget, render_kw=render_kw, **extra_arguments ) super().__init__( unbound_field, label, min_entries=0, max_entries=None, id=id, default=default, widget=widget, # type:ignore[arg-type] render_kw=render_kw, name=name, _form=_form, _prefix=_prefix, _translations=_translations, _meta=_meta )
[docs] def __bool__(self) -> Literal[True]: # because FieldList implements __len__ this field would evaluate # to False if no files have been uploaded, which is not generally # what we want return True
[docs] def process( self, formdata: '_MultiDictLikeWithGetlist | None', data: object = unset_value, extra_filters: 'Sequence[Filter] | None' = None ) -> None: self.process_errors = [] # process the sub-fields super().process(formdata, data=data, extra_filters=extra_filters) # process the top-level multiple file field if formdata is not None: if self.name in formdata: self.raw_data = formdata.getlist(self.name) else: self.raw_data = [] try: self.process_formdata(self.raw_data) except ValueError as e: self.process_errors.append(e.args[0])
[docs] def append_entry_from_field_storage( self, fs: '_FieldStorageWithFile' ) -> UploadField: # we fake the formdata for the new field # we use a werkzeug MultiDict because the WebOb version # needs to get wrapped to be usable in WTForms formdata: MultiDict[str, RawFormValue] = MultiDict() name = f'{self.short_name}{self._separator}{len(self)}' formdata.add(name, fs) return self._add_entry(formdata)
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: if not valuelist: return # only create entries for valid field storage for value in valuelist: if isinstance(value, str): continue if hasattr(value, 'file') or hasattr(value, 'stream'): self.append_entry_from_field_storage(value)
[docs] class _DummyFile:
[docs] file: 'File | None'
[docs] class UploadMultipleFilesWithORMSupport(UploadMultipleField): """ Extends the upload multiple field with onegov.file support. """
[docs] file_class: type['File']
[docs] added_files: list['File']
[docs] upload_field_class = UploadFileWithORMSupport
def __init__(self, *args: Any, **kwargs: Any): self.file_class = kwargs['file_class'] super().__init__(*args, **kwargs)
[docs] def populate_obj(self, obj: object, name: str) -> None: self.added_files = [] files = getattr(obj, name, ()) output: list[File] = [] for field, file in zip_longest(self.entries, files): if field is None: # this generally shouldn't happen, but we should # guard against it anyways, since it can happen # if people manually call pop_entry() break dummy = _DummyFile() dummy.file = file field.populate_obj(dummy, 'file') # avoid generating multiple links to the same file if dummy.file is not None and dummy.file not in output: output.append(dummy.file) if ( dummy.file is not file # an upload field may mark a file as having already # existed previously, in this case we don't consider # it having being added and getattr(field, 'existing_file', None) is None ): # added file self.added_files.append(dummy.file) setattr(obj, name, output)
[docs] class TextAreaFieldWithTextModules(TextAreaField): """ A textfield with text module selection/insertion. """
[docs] widget = TextAreaWithTextModules()
[docs] class VideoURLField(URLField): pass
[docs] class HtmlField(TextAreaField): """ A textfield with html with integrated sanitation. """
[docs] data: Markup | None
def __init__(self, *args: Any, **kwargs: Any):
[docs] self.form = kwargs.get('_form')
if 'render_kw' not in kwargs or not kwargs['render_kw'].get('class_'): kwargs['render_kw'] = kwargs.get('render_kw', {}) kwargs['render_kw']['class_'] = 'editor' super().__init__(*args, **kwargs)
[docs] def pre_validate(self, form: 'BaseForm') -> None: self.data = sanitize_html(self.data)
[docs] class CssField(TextAreaField): """ A textfield with css validation. """
[docs] def post_validate( self, form: 'BaseForm', validation_stopped: bool ) -> None: if self.data: try: CSSStyleSheet().cssText = self.data except Exception as exception: raise ValidationError(str(exception)) from exception
[docs] class MarkupField(TextAreaField): """ A textfield with markup with no sanitation. This field is inherently unsafe and should be avoided, use with care! """
[docs] data: Markup | None
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: if valuelist: assert isinstance(valuelist[0], str) self.data = Markup(valuelist[0]) # noqa: RUF035 else: self.data = None
[docs] def process_data(self, value: str | None) -> None: # NOTE: For regular data we do the escape, just to ensure # that we use this field consistenly and don't pass # in raw strings self.data = escape(value) if value is not None else None
[docs] class TagsField(StringField): """ A tags field for use in conjunction with this library: https://github.com/developit/tags-input """
[docs] widget = TagsWidget()
# FIXME: Why does data have a different shape depending on if it's # passed in by the form or the object?! This seems like a bug
[docs] data: str | list[str] # type:ignore[assignment]
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: if not valuelist: self.data = [] return values_str = valuelist[0] if isinstance(values_str, str) and values_str != '[]': # FIXME: Shouldn't this strip [] from the ends? values = (v.strip() for v in values_str.split(',')) self.data = [v for v in values if v] else: self.data = []
[docs] def process_data(self, value: list[str] | None) -> None: self.data = ','.join(value) if value else ''
[docs] class IconField(StringField): """ Selects an icon out of a number of icons. """
[docs] widget = IconWidget()
[docs] class PhoneNumberField(TelField): """ A string field with support for phone numbers. """ def __init__(self, *args: Any, country: str = 'CH', **kwargs: Any):
[docs] self.country = country
super().__init__(*args, **kwargs) # ensure the ValidPhoneNumber validator gets added if not any(isinstance(v, ValidPhoneNumber) for v in self.validators): # validators can be any sequence type, so it might not be mutable # so we have to first convert to a list if that's the case if not isinstance(self.validators, list): self.validators = list(self.validators) self.validators.append(ValidPhoneNumber(self.country)) @property
[docs] def formatted_data(self) -> str | None: try: return phonenumbers.format_number( phonenumbers.parse(self.data or '', self.country), phonenumbers.PhoneNumberFormat.E164 ) except Exception: return self.data
[docs] class ChosenSelectField(SelectField): """ A select field with chosen.js support. """
[docs] widget = ChosenSelectWidget()
[docs] class ChosenSelectMultipleField(SelectMultipleField): """ A multiple select field with chosen.js support. """
[docs] widget = ChosenSelectWidget(multiple=True)
[docs] class ChosenSelectMultipleEmailField(SelectMultipleField):
[docs] widget = ChosenSelectWidget(multiple=True)
[docs] def pre_validate(self, form: 'BaseForm') -> None: super().pre_validate(form) if not self.data: return for email in self.data: try: validate_email(email) except EmailNotValidError as e: raise ValidationError(_('Not a valid email')) from e
[docs] class PreviewField(Field):
[docs] fields: 'Sequence[str]'
[docs] events: 'Sequence[str]'
[docs] url: 'Callable[[DefaultMeta], str | None] | str | None'
[docs] display: str
[docs] widget = PreviewWidget()
def __init__( self, *args: Any, fields: 'Sequence[str]' = (), url: 'Callable[[DefaultMeta], str | None] | str | None' = None, events: 'Sequence[str]' = (), display: str = 'inline', **kwargs: Any ): self.fields = fields self.url = url self.events = events self.display = display super().__init__(*args, **kwargs)
[docs] def populate_obj(self, obj: object, name: str) -> None: pass
[docs] class PanelField(Field): """ Shows a panel as part of the form (no input, no label). """
[docs] widget = PanelWidget()
def __init__( self, *args: Any, text: str, kind: str, hide_label: bool = True, **kwargs: Any ):
[docs] self.text = text
[docs] self.kind = kind
[docs] self.hide_label = hide_label
super().__init__(*args, **kwargs)
[docs] def populate_obj(self, obj: object, name: str) -> None: pass
[docs] class DateTimeLocalField(DateTimeLocalFieldBase): """ A custom implementation of the DateTimeLocalField to fix issues with the format and the datetimepicker plugin. """ def __init__( self, label: str | None = None, validators: 'Validators[FormT, Self] | None' = None, format: str = '%Y-%m-%dT%H:%M', **kwargs: Any ): super().__init__( label=label, validators=validators, format=format, **kwargs )
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: if valuelist: date_str = 'T'.join(valuelist).replace(' ', 'T') # type:ignore valuelist = [date_str[:16]] super().process_formdata(valuelist)
[docs] class TimezoneDateTimeField(DateTimeLocalField): """ A datetime field data returns the date with the given timezone and expects dateime values with a timezone. Used together with :class:`onegov.core.orm.types.UTCDateTime`. """
[docs] data: 'datetime | None'
def __init__(self, *args: Any, timezone: str, **kwargs: Any):
[docs] self.timezone = timezone
super().__init__(*args, **kwargs)
[docs] def process_data(self, value: 'datetime | None') -> None: if value: value = sedate.to_timezone(value, self.timezone) value.replace(tzinfo=None) super().process_data(value)
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: super().process_formdata(valuelist) if self.data: self.data = sedate.replace_timezone(self.data, self.timezone)
[docs] class HoneyPotField(StringField): """ A field to identify bots. A honey pot field is hidden using CSS and therefore not visible for users but bots (probably). We therefore expect this field to be empty at any time and throw an error if provided as well as adding a log message to optionally ban the IP. To add honey pot fields to your (public) forms, give it a reasonable name, but not one that might be autofilled by browsers, e.g.: delay = HoneyPotField() """
[docs] widget = HoneyPotWidget()
def __init__(self, *args: Any, **kwargs: Any): kwargs['label'] = '' kwargs['validators'] = '' kwargs['description'] = '' kwargs['default'] = '' super().__init__(*args, **kwargs)
[docs] self.type = 'LazyWolvesField'
[docs] def post_validate( self, form: 'Form', # type:ignore[override] validation_stopped: bool ) -> None: if self.data: log.info(f'Honeypot used by {form.request.client_addr}') raise ValidationError('Invalid value')
[docs] class ColorField(StringField): """ A string field that renders a html5 color picker and coerces to a normalized six digit hex string. It will result in a process_error for invalid colors. """
[docs] widget = ColorInput()
[docs] def coerce(self, value: object) -> str | None: if not isinstance(value, str) or value == '': return None try: if not value.startswith('#'): value = name_to_hex(value) return normalize_hex(value) except ValueError: msg = self.gettext(_('Not a valid color.')) raise ValueError(msg) from None
[docs] def process_data(self, value: object) -> None: self.data = self.coerce(value)
[docs] def process_formdata(self, valuelist: list['RawFormValue']) -> None: if not valuelist: self.data = None return self.data = self.coerce(valuelist[0])
[docs] class TypeAheadField(StringField): """ A string field with typeahead. Requires an url with the placeholder `%QUERY` for the search term. """
[docs] url: 'Callable[[DefaultMeta], str | None] | str | None'
[docs] widget = TypeAheadInput()
def __init__( self, *args: Any, url: 'Callable[[DefaultMeta], str | None] | str | None' = None, **kwargs: Any ): self.url = url super().__init__(*args, **kwargs)