from __future__ import annotations
from decimal import Decimal
from html import escape
from functools import cached_property
from io import StringIO
from onegov.form import errors, log
from onegov.form.core import Form
from onegov.form.fields import (
MultiCheckboxField, DateTimeLocalField, URLField, VideoURLField)
from onegov.form.fields import TimeField, UploadField, UploadMultipleField
from onegov.form.parser.core import flatten_fields, parse_formcode, ParsedField
from onegov.form.validators import LaxDataRequired
from onegov.form.validators import ExpectedExtensions
from onegov.form.validators import FileSizeLimit
from onegov.form.validators import Stdnum
from onegov.form.validators import StrictOptional
from onegov.form.validators import ValidDateRange
from onegov.form.widgets import DateRangeInput
from onegov.form.widgets import DateTimeLocalRangeInput
from pydantic import BaseModel, ConfigDict, Field
from wtforms.fields import DateField
from wtforms.fields import DecimalField
from wtforms.fields import EmailField
from wtforms.fields import IntegerField
from wtforms.fields import PasswordField
from wtforms.fields import RadioField
from wtforms.fields import StringField
from wtforms.fields import TextAreaField
from wtforms.validators import Email
from wtforms.validators import Length
from wtforms.validators import NumberRange
from wtforms.validators import Regexp
from wtforms.validators import URL
from typing import Any, Self, TYPE_CHECKING
if TYPE_CHECKING:
from onegov.form.types import PricingRules, Validator, Widget
from wtforms.fields.core import Field as WTField
[docs]
DEFAULT_UPLOAD_LIMIT = 100 * MEGABYTE
# FIXME: We can probably get rid of this function and instead just
# rely on `ParsedForm`.
[docs]
def handle_field(
builder: WTFormsClassBuilder[Any],
field: ParsedField,
depends_on: tuple[str, str] | None = None,
) -> None:
""" Takes the given parsed field and adds it to the form. """
validators: list[Validator[Any, Any]]
widget: Widget[Any] | None
if field.type == 'text':
render_kw = None
if field.maxlength:
validators = [Length(max=field.maxlength)]
render_kw = {'data-max-length': field.maxlength}
else:
validators = []
if field.regex:
validators.append(Regexp(field.regex))
builder.add_field(
field_class=StringField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=validators,
render_kw=render_kw,
description=field.field_help
)
elif field.type == 'textarea':
builder.add_field(
field_class=TextAreaField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
render_kw={'rows': field.rows} if field.rows else None,
description=field.field_help
)
elif field.type == 'password':
builder.add_field(
field_class=PasswordField,
field_id=field.id,
fieldset=field.real_fieldset,
label=field.display_label,
depends_on=depends_on,
required=field.required,
description=field.field_help
)
elif field.type == 'email':
builder.add_field(
field_class=EmailField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[Email()],
description=field.field_help
)
elif field.type == 'url':
builder.add_field(
field_class=URLField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[URL()],
description=field.field_help
)
elif field.type == 'video_url':
builder.add_field(
field_class=VideoURLField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[URL()],
description=field.field_help
)
elif field.type == 'stdnum':
builder.add_field(
field_class=StringField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[Stdnum(field.format)],
description=field.field_help
)
elif field.type == 'date':
widget = None
validators = []
if field.valid_date_range:
start = field.valid_date_range.start
stop = field.valid_date_range.stop
widget = DateRangeInput(start, stop)
validators.append(ValidDateRange(start, stop))
builder.add_field(
field_class=DateField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
description=field.field_help,
validators=validators,
widget=widget
)
elif field.type == 'datetime':
widget = None
validators = []
if field.valid_date_range:
start = field.valid_date_range.start
stop = field.valid_date_range.stop
widget = DateTimeLocalRangeInput(start, stop)
validators.append(ValidDateRange(start, stop))
builder.add_field(
field_class=DateTimeLocalField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
description=field.field_help,
validators=validators,
widget=widget
)
elif field.type == 'time':
builder.add_field(
field_class=TimeField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
description=field.field_help
)
elif field.type == 'fileinput':
expected_extensions = ExpectedExtensions(field.extensions)
# build an accept attribute for the file input
accept = ','.join(expected_extensions.whitelist)
builder.add_field(
field_class=UploadField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[
FileSizeLimit(DEFAULT_UPLOAD_LIMIT)
],
allowed_mimetypes=expected_extensions.whitelist,
render_kw={'accept': accept},
description=field.field_help
)
elif field.type == 'multiplefileinput':
expected_extensions = ExpectedExtensions(field.extensions)
# build an accept attribute for the file input
accept = ','.join(expected_extensions.whitelist)
builder.add_field(
field_class=UploadMultipleField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[
FileSizeLimit(DEFAULT_UPLOAD_LIMIT)
],
allowed_mimetypes=expected_extensions.whitelist,
render_kw={'accept': accept},
description=field.field_help
)
elif field.type == 'radio':
builder.add_field(
field_class=RadioField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
choices=[(c.label, c.display_label) for c in field.choices],
default=next((c.label for c in field.choices if c.selected), None),
pricing={
c.label: c.pricing.as_tuple()
for c in field.choices
if c.pricing is not None
} or None,
discount={
c.label: c.discount / Decimal('100')
for c in field.choices
if c.discount is not None
} or None,
# do not coerce None into 'None'
coerce=lambda v: str(v) if v is not None else v,
description=field.field_help
)
elif field.type == 'checkbox':
builder.add_field(
field_class=MultiCheckboxField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
choices=[(c.label, c.display_label) for c in field.choices],
default=[c.label for c in field.choices if c.selected],
pricing={
c.label: c.pricing.as_tuple()
for c in field.choices
if c.pricing is not None
} or None,
discount={
c.label: c.discount / Decimal('100')
for c in field.choices
if c.discount is not None
} or None,
# do not coerce None into 'None'
coerce=lambda v: str(v) if v is not None else v,
description=field.field_help
)
elif field.type == 'integer_range':
builder.add_field(
field_class=IntegerField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
pricing={
range(
field.range.start,
field.range.stop
): field.pricing_per_item.as_tuple()
} if field.pricing_per_item is not None else None,
validators=[
NumberRange(
field.range.start,
field.range.stop
)
],
description=field.field_help
)
elif field.type == 'decimal_range':
builder.add_field(
field_class=DecimalField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[
NumberRange(
field.range.start,
field.range.stop
)
],
description=field.field_help
)
elif field.type == 'chip_nr':
builder.add_field(
field_class=StringField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
validators=[Regexp(r'^[0-9]{15}$')],
description=field.field_help
)
elif field.type == 'code':
builder.add_field(
field_class=TextAreaField,
field_id=field.id,
label=field.display_label,
fieldset=field.real_fieldset,
depends_on=depends_on,
required=field.required,
render_kw={'data-editor': field.syntax},
description=field.field_help
)
else:
raise NotImplementedError
if field.type == 'radio' or field.type == 'checkbox':
# NOTE: We have to walk the subfields in a specific order in order
# to make sure we don't create too many fieldsets
subfield_stacks = [
(choice, list(reversed(choice.fields)))
for choice in field.choices
if choice.fields
]
if not subfield_stacks:
return
current_fieldset = field.real_fieldset
branch_index = 0
for _ in range(sum(len(stack) for _, stack in subfield_stacks)):
for idx in range(branch_index, len(subfield_stacks)):
choice, stack = subfield_stacks[idx]
if stack and stack[-1].real_fieldset == current_fieldset:
branch_index = idx
subfield = stack.pop()
handle_field(
builder,
subfield,
depends_on=(field.id, choice.label)
)
break
else:
# none of the candidates match the current fieldset
# go back to the first non-empty branch and update
# the current fieldset
for idx, (choice, stack) in enumerate(subfield_stacks):
if stack:
branch_index = idx
subfield = stack.pop()
current_fieldset = subfield.real_fieldset
handle_field(
builder,
subfield,
depends_on=(field.id, choice.label)
)
break
# NOTE: Sanity check to make sure we depleted all of the stacks
assert all(not stack for _, stack in subfield_stacks)