from __future__ import annotations
import humanize
import importlib
import phonenumbers
import re
from babel.dates import format_date
from cgi import FieldStorage
from datetime import date
from datetime import datetime
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from mimetypes import types_map
from onegov.form import _
from onegov.form.errors import (DuplicateLabelError, InvalidIndentSyntax,
EmptyFieldsetError)
from onegov.form.errors import FieldCompileError
from onegov.form.errors import InvalidFormSyntax
from onegov.form.errors import MixedTypeError
from onegov.form.types import BaseFormT, FieldT
from stdnum.exceptions import ( # type:ignore[import-untyped]
ValidationError as StdnumValidationError)
from wtforms import DateField, DateTimeLocalField, RadioField, TimeField
from wtforms.fields import SelectField
from wtforms.validators import DataRequired
from wtforms.validators import InputRequired
from wtforms.validators import Optional
from wtforms.validators import StopValidation
from wtforms.validators import ValidationError
from typing import Generic, TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection, Sequence
from onegov.core.orm import Base
from onegov.form import Form
from onegov.form.types import BaseValidator, FieldCondition
from wtforms import Field
from wtforms.form import BaseForm
# HACK: We extend the default type map with additional entries for file endings
# that sometimes don't have a single agreed upon mimetype, we may need
# to do something more clever in the future and map single file endings
# to multiple mime types.
types_map.setdefault('.mp3', 'audio/mpeg')
[docs]
class If(Generic[BaseFormT, FieldT]):
""" Wraps a single validator or a list of validators, which will
only be executed if the supplied condition callback returns `True`.
"""
def __init__(
self,
condition: FieldCondition[BaseFormT, FieldT],
*validators: BaseValidator[BaseFormT, FieldT]
):
assert len(validators) > 0, 'Need to supply at least one validator'
[docs]
self.condition = condition
[docs]
self.validators = validators
[docs]
def __call__(self, form: BaseFormT, field: FieldT) -> None:
if not self.condition(form, field):
return
for validator in self.validators:
validator(form, field)
[docs]
class Stdnum:
""" Validates a string using any python-stdnum format.
See `<https://github.com/arthurdejong/python-stdnum>`_.
"""
def __init__(self, format: str):
module = '.'.join(p for p in format.split('.') if p)
[docs]
def __call__(self, form: Form, field: Field) -> None:
# only do a check for filled out values, to check for the existance
# of any value use DataRequired!
if not field.data:
return
try:
self.format.validate(field.data)
except StdnumValidationError as exception:
raise ValidationError(
field.gettext('Invalid input.')
) from exception
[docs]
class FileSizeLimit:
""" Makes sure an uploaded file is not bigger than the given number of
bytes.
Expects an :class:`onegov.form.fields.UploadField` or
:class:`onegov.form.fields.UploadMultipleField` instance.
"""
[docs]
message = _(
'The file is too large, please provide a file smaller than {}.'
)
def __init__(self, max_bytes: int):
[docs]
self.max_bytes = max_bytes
[docs]
def __call__(self, form: Form, field: Field) -> None:
if not field.data:
return
if field.data.get('size', 0) > self.max_bytes:
message = field.gettext(self.message).format(
humanize.naturalsize(self.max_bytes)
)
raise ValidationError(message)
[docs]
class WhitelistedMimeType:
""" Makes sure an uploaded file is in a whitelist of allowed mimetypes.
Expects an :class:`onegov.form.fields.UploadField` or
:class:`onegov.form.fields.UploadMultipleField` instance.
"""
[docs]
whitelist: Collection[str] = {
'application/excel',
'application/vnd.ms-excel',
'application/msword',
'application/pdf',
'application/zip',
'image/gif',
'image/jpeg',
'image/png',
'image/x-ms-bmp',
'text/plain',
'text/csv'
}
[docs]
message = _('Files of this type are not supported.')
def __init__(self, whitelist: Collection[str] | None = None):
if whitelist is not None:
self.whitelist = whitelist
[docs]
def __call__(self, form: Form, field: Field) -> None:
if not field.data:
return
if field.data['mimetype'] not in self.whitelist:
raise ValidationError(field.gettext(self.message))
[docs]
class ExpectedExtensions(WhitelistedMimeType):
""" Makes sure an uploaded file has one of the expected extensions. Since
extensions are not something we can count on we look up the mimetype of
the extension and use that to check.
Expects an :class:`onegov.form.fields.UploadField` instance.
Usage::
ExpectedExtensions(['*']) # default whitelist
ExpectedExtensions(['pdf']) # makes sure the given file is a pdf
"""
def __init__(self, extensions: Sequence[str]):
# normalize extensions
if len(extensions) == 1 and extensions[0] == '*':
mimetypes = None
else:
mimetypes = {
mimetype for ext in extensions
# we silently discard any extensions we don't know for now
if (mimetype := types_map.get('.' + ext.lstrip('.'), None))
}
super().__init__(whitelist=mimetypes)
[docs]
class ValidSurveyDefinition(ValidFormDefinition):
""" Makes sure the given text is a valid onegov.form definition for
surveys.
"""
def __init__(self, require_email_field: bool = False):
super().__init__(require_email_field)
[docs]
invalid_field_type = _("Invalid field type for field '${label}'. Please "
"use the plus-icon to add allowed field types.")
[docs]
def __call__(self, form: Form, field: Field) -> Form | None:
from onegov.form.fields import UploadField
parsed_form = super().__call__(form, field)
if parsed_form is None:
return None
# Exclude fields that are not allowed in surveys
errors = None
for field in parsed_form._fields.values():
if isinstance(field, (UploadField, DateField, TimeField,
DateTimeLocalField)):
error = field.gettext(self.invalid_field_type %
{'label': field.label.text})
errors = form['definition'].errors
if not isinstance(errors, list):
errors = form['definition'].process_errors
assert isinstance(errors, list)
errors.append(error)
if errors:
raise ValidationError()
return parsed_form
[docs]
class LaxDataRequired(DataRequired):
""" A copy of wtform's DataRequired validator, but with a more lax approach
to required validation checking. It accepts some specific falsy values,
such as numeric falsy values, that would otherwise fail DataRequired.
This is necessary in order for us to validate stored submissions, which
get validated after the initial submission in order to avoid losing file
uploads.
"""
[docs]
def __call__(self, form: BaseForm, field: Field) -> None:
if field.data is False:
# guard against False, False is an instance of int, since
# bool derives from int, so we need to check this first
pass
elif isinstance(field.data, (int, float, Decimal)):
# we just accept any numeric data regardless of amount
return
# fall back to wtform's validator
super().__call__(form, field)
[docs]
class StrictOptional(Optional):
""" A copy of wtform's Optional validator, but with a more strict approach
to optional validation checking.
See https://github.com/wtforms/wtforms/issues/350
"""
[docs]
def is_missing(self, value: object) -> bool:
if isinstance(value, FieldStorage):
return False
if not value:
return True
if isinstance(value, str):
return not self.string_check(value)
return False
[docs]
def __call__(self, form: BaseForm, field: Field) -> None:
raw = field.raw_data and field.raw_data[0]
val = field.data
# the selectfields have this annyoing habit of coercing all values
# that are added to them -> this includes the None, which is turned
# into 'None'
if isinstance(field, SelectField) and val == 'None':
val = None
if self.is_missing(raw) and self.is_missing(val):
field.errors = []
raise StopValidation()
[docs]
class ValidPhoneNumber:
""" Makes sure the given input is valid phone number.
Expects an :class:`wtforms.StringField` instance.
"""
[docs]
message = _('Not a valid phone number.')
def __init__(
self,
country: str = 'CH',
country_whitelist: Collection[str] | None = None
):
if country_whitelist:
assert country in country_whitelist
[docs]
self.country_whitelist = country_whitelist
[docs]
def __call__(self, form: Form, field: Field) -> None:
if not field.data:
return
try:
number = phonenumbers.parse(field.data, self.country)
except Exception as exception:
raise ValidationError(self.message) from exception
if self.country_whitelist:
region = phonenumbers.region_code_for_number(number)
if region not in self.country_whitelist:
# FIXME: Better error message?
raise ValidationError(self.message)
if not (
phonenumbers.is_valid_number(number)
and phonenumbers.is_possible_number(number)
):
raise ValidationError(self.message)
[docs]
swiss_ssn_rgxp = re.compile(r'756\.\d{4}\.\d{4}\.\d{2}$')
[docs]
class ValidSwissSocialSecurityNumber:
""" Makes sure the given input is a valid swiss social security number.
Expects an :class:`wtforms.StringField` instance.
"""
[docs]
message = _('Not a valid swiss social security number.')
[docs]
def __call__(self, form: Form, field: Field) -> None:
if not field.data:
return
if not re.match(swiss_ssn_rgxp, field.data):
raise ValidationError(self.message)
[docs]
class UniqueColumnValue:
""" Test if the given table does not already have a value in the column
(identified by the field name).
If the form provides a model with such an attribute, we allow this
value, too.
Usage::
username = StringField(validators=[UniqueColumnValue(User)])
"""
def __init__(self, table: type[Base]):
[docs]
def __call__(self, form: Form, field: Field) -> None:
if field.name not in self.table.__table__.columns: # type:ignore
raise RuntimeError('The field name must match a column!')
if hasattr(form, 'model'):
if hasattr(form.model, field.name):
if getattr(form.model, field.name) == field.data:
return
column = getattr(self.table, field.name)
query = form.request.session.query(column)
query = query.filter(column == field.data)
if query.first():
raise ValidationError(_('This value already exists.'))
[docs]
class ValidDateRange:
"""
Makes sure the selected date is in a valid range.
The default error message can be overriden and be parametrized
with ``min_date`` and ``max_date`` if both are supplied or just
with ``date`` if only one of them is specified.
"""
[docs]
between_message = _('Needs to be between {min_date} and {max_date}.')
[docs]
after_message = _('Needs to be on or after {date}.')
[docs]
before_message = _('Needs to be on or before {date}.')
def __init__(
self,
min: date | relativedelta | None = None,
max: date | relativedelta | None = None,
message: str | None = None
):
if message is not None:
self.message = message
elif min is None:
assert max is not None, 'Need to supply either min or max'
self.message = self.before_message
elif max is None:
self.message = self.after_message
else:
self.message = self.between_message
@property
[docs]
def min_date(self) -> date | None:
if isinstance(self.min, relativedelta):
return date.today() + self.min
return self.min
@property
[docs]
def max_date(self) -> date | None:
if isinstance(self.max, relativedelta):
return date.today() + self.max
return self.max
[docs]
def __call__(self, form: Form, field: Field) -> None:
if field.data is None:
return
value = field.data
if isinstance(value, datetime):
value = value.date()
assert isinstance(value, date)
if hasattr(form, 'request'):
locale = form.request.locale
else:
locale = 'de_CH'
min_date = self.min_date
max_date = self.max_date
if min_date is not None and max_date is not None:
# FIXME: To be properly I18n just like with `Layout.format_date`
# the date format should depend on the locale.
if not (min_date <= value <= max_date):
min_str = format_date(
min_date, format='dd.MM.yyyy', locale=locale)
max_str = format_date(
max_date, format='dd.MM.yyyy', locale=locale)
raise ValidationError(field.gettext(self.message).format(
min_date=min_str, max_date=max_str
))
elif min_date is not None and value < min_date:
min_str = format_date(min_date, format='dd.MM.yyyy', locale=locale)
raise ValidationError(
field.gettext(self.message).format(date=min_str)
)
elif max_date is not None and value > max_date:
max_str = format_date(max_date, format='dd.MM.yyyy', locale=locale)
raise ValidationError(
field.gettext(self.message).format(date=max_str)
)