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):
# 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]
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]
class OrderedMultiCheckboxField(MultiCheckboxField):
[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]
action: Literal['keep', 'replace', 'delete']
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_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]
raw_data: list['RawFormValue']
if TYPE_CHECKING:
def _add_entry(self, d: _MultiDictLikeWithGetlist, /) -> UploadField:
...
[docs]
upload_field_class: type[UploadField] = UploadField
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]
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. """
def __init__(self, *args: Any, **kwargs: Any):
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]
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 IconField(StringField):
""" Selects an icon out of a number of icons. """
[docs]
class PhoneNumberField(TelField):
""" A string field with support for phone numbers. """
def __init__(self, *args: Any, country: str = 'CH', **kwargs: Any):
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]
class ChosenSelectField(SelectField):
""" A select field with chosen.js support. """
[docs]
class ChosenSelectMultipleField(SelectMultipleField):
""" A multiple select field with chosen.js support. """
[docs]
class ChosenSelectMultipleEmailField(SelectMultipleField):
[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'
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). """
def __init__(
self,
*args: Any,
text: str,
kind: str,
hide_label: bool = True,
**kwargs: Any
):
[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]
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]
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()
"""
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]
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]
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'
def __init__(
self,
*args: Any,
url: 'Callable[[DefaultMeta], str | None] | str | None' = None,
**kwargs: Any
):
self.url = url
super().__init__(*args, **kwargs)