Source code for form.validators

from __future__ import annotations

import humanize
import importlib
import phonenumbers

from babel.dates import format_date
from bad_passwords import is_bad_password
from cgi import FieldStorage
from datetime import date
from datetime import datetime
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from io import BytesIO
from mimetypes import types_map
from onegov.core.utils import binary_to_dictionary, dictionary_to_binary
from onegov.file.attachments import resize_image
from onegov.file.utils import get_supported_image_mime_types
from onegov.form import _
from stdnum.exceptions import (
    ValidationError as StdnumValidationError)
from wtforms.fields import SelectField
from wtforms.validators import DataRequired
from wtforms.validators import InputRequired
from wtforms.validators import HostnameValidation
from wtforms.validators import Length
from wtforms.validators import Optional
from wtforms.validators import StopValidation
from wtforms.validators import ValidationError


from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Collection, Sequence
    from onegov.form import Form
    from onegov.form.fields import FormcodeField
    from onegov.form.types import BaseValidator, FieldCondition
    from sqlalchemy.orm import DeclarativeBase
    from wtforms import Field, StringField
    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[FormT: BaseForm, FieldT: Field]: """ 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[FormT, FieldT], *validators: BaseValidator[FormT, 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: FormT, 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] self.format = importlib.import_module('stdnum.' + module)
[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 isinstance(field.data, list): # UploadMultipleField for data in field.data: if not data: continue # in case of file deletion 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 ImageSizeLimit(FileSizeLimit): """ Like :class:`FileSizeLimit` but with a default suited for image uploads, image-specific error messaging, and optional automatic resampling. When *max_dimensions* is given, images whose longest side exceeds that value are resampled (LANCZOS) before the byte-size check runs, so over-sized images silently shrink rather than trigger a validation error — unless the resampled image still exceeds *max_bytes*. Expects an :class:`onegov.form.fields.UploadField` instance. """
[docs] DEFAULT_MAX_BYTES = 20_000_000 # 20 MB
[docs] message = _( 'The image is too large, please provide an image smaller than {}.' )
def __init__( self, max_bytes: int = DEFAULT_MAX_BYTES, max_dimensions: int | None = None, ): super().__init__(max_bytes)
[docs] self.max_dimensions = max_dimensions
[docs] def __call__(self, form: Form, field: Field) -> None: if (field.data and self.max_dimensions is not None and isinstance(field.data, dict) and field.data.get('mimetype', '').startswith('image/') and 'data' in field.data): self._maybe_resize(field) super().__call__(form, field)
[docs] def _maybe_resize(self, field: Field) -> None: assert self.max_dimensions is not None try: binary = dictionary_to_binary(field.data) except Exception: return buf = BytesIO(binary) resized = resize_image(buf, self.max_dimensions) if resized is buf: return filename = field.data.get('filename') field.data = binary_to_dictionary(resized.read(), filename)
[docs] MIME_TYPES_PDF = { 'application/pdf', }
# for now not allowed by default
[docs] MIME_TYPES_JSON = { 'application/json', }
[docs] MIME_TYPES_DOCUMENT = { 'application/msword', # doc 'application/rtf', *MIME_TYPES_PDF, 'application/excel', 'application/vnd.ms-excel', # xls ('application/vnd.openxmlformats-officedocument.' 'presentationml.presentation'), # pptx ('application/vnd.openxmlformats-officedocument.' 'spreadsheetml.sheet'), # xlsx ('application/vnd.openxmlformats-officedocument.' 'wordprocessingml.document'), # docx 'application/vnd.ms-office', 'application/CDFV2', # old ms office docs 'application/x-ole-storage', # old ms office docs 'application/CDFV2-unknown' # old ms office docs }
[docs] MIME_TYPES_EXCEL = { 'application/excel', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-office', 'application/octet-stream', 'application/x-ole-storage', }
[docs] MIME_TYPES_XML = { 'application/xml', }
[docs] MIME_TYPES_ARCHIVE = { 'application/zip', }
[docs] MIME_TYPES_TEXT_DATA = { 'text/csv', 'text/plain', }
[docs] MIME_TYPES_IMAGE = { # allowed types based on PIL *get_supported_image_mime_types(), 'image/svg+xml', }
[docs] MIME_TYPES_AUDIO = { 'audio/mp4', 'audio/mpeg', 'audio/wav', 'audio/webm', # weba 'application/octet-stream', # fallback landsgemeinde }
[docs] MIME_TYPES_VIDEO = { 'video/mp4', 'video/mpeg', # mpg, mpeg 'video/ogg', 'video/quicktime', # mov 'video/webm', # webm 'video/x-msvideo', # avi }
[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] = { *MIME_TYPES_DOCUMENT, *MIME_TYPES_XML, *MIME_TYPES_ARCHIVE, *MIME_TYPES_TEXT_DATA, *MIME_TYPES_IMAGE, *MIME_TYPES_AUDIO, *MIME_TYPES_VIDEO, }
[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 ValidPassword(Length): """ Makes sure the given password is not part of a list of commonly used passwords. """ def __init__( self, min_length: int = 10, length_message: str | None = None ) -> None: assert min_length >= 10 super().__init__(min=min_length, message=length_message)
[docs] def __call__(self, form: BaseForm, field: StringField) -> None: super().__call__(form, field) assert field.data is not None if is_bad_password(field.data): raise ValidationError(_( 'The password you wanted to use was found on ' 'list of commonly used passwords, please use ' 'a more secure password.' ))
[docs] class ValidFilterFormDefinition:
[docs] invalid_field_type = _("Invalid field type for field '{label}'. For " "filters only 'select' or 'multiple select' " "fields are allowed.")
[docs] def __call__(self, form: Form, field: FormcodeField) -> None: if field.data is None: return # limit the definition to MultiCheckboxField, RadioField which can # be used for filter definition errors = None for parsed_field in field.data.flattened_fields: if parsed_field.type not in ('checkbox', 'radio'): error = field.gettext(self.invalid_field_type).format( label=parsed_field.label) errors = field.errors if not isinstance(errors, list): errors = field.process_errors assert isinstance(errors, list) errors.append(error) if errors: raise ValidationError()
[docs] class ValidSurveyDefinition: """ Makes sure the given text is a valid onegov.form definition for surveys. """
[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: FormcodeField) -> None: if field.data is None: return # Exclude fields that are not allowed in surveys errors = None for parsed_field in field.data.flattened_fields: if field.type in ( 'fileinput', 'multiplefileinput', 'date', 'datetime', 'time' ): message = self.invalid_field_type % { 'label': field.label.text} error = field.gettext(message) 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()
[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 """ def __init__( self, strip_whitespace: bool = True, zero_is_optional: bool = False ) -> None:
[docs] self.zero_is_optional = zero_is_optional
super().__init__(strip_whitespace=strip_whitespace)
[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) or ( self.zero_is_optional and val == 0 ): field.errors = [] raise StopValidation()
[docs] class ValidPhoneNumber: """ Makes sure the given input is valid phone number. Expects an :class:`wtforms.StringField` instance. ``number_type`` restricts the number to a single :class:`phonenumbers.PhoneNumberType` (``None``, the default, accepts any). """
[docs] invalid_phone_number = _('Not a valid phone number.')
[docs] invalid_country_code = _('Not a valid country code.')
[docs] invalid_number_length = _('This phone number has an invalid length.')
[docs] missing_area_code = _('Please include the area code.')
[docs] unknown_number = _('This phone number does not exist.')
[docs] mobile_required = _('Please enter a mobile phone number.')
[docs] fixed_line_required = _('Please enter a landline phone number.')
[docs] unsupported_country = _( 'Phone numbers from this country are not supported. ' 'Allowed countries: ${countries}' )
[docs] length_errors = { phonenumbers.ValidationResult.TOO_SHORT: invalid_number_length, phonenumbers.ValidationResult.TOO_LONG: invalid_number_length, phonenumbers.ValidationResult.INVALID_LENGTH: invalid_number_length, phonenumbers.ValidationResult.IS_POSSIBLE_LOCAL_ONLY: missing_area_code, }
# error shown when a required number type isn't met
[docs] type_required_errors = { phonenumbers.PhoneNumberType.MOBILE: mobile_required, phonenumbers.PhoneNumberType.FIXED_LINE: fixed_line_required, }
def __init__( self, country: str = 'CH', country_whitelist: Collection[str] | None = None, number_type: int | None = None ): if country_whitelist: assert country in country_whitelist, ( 'Invalid country code: {}. Allowed are: {}'.format( country, sorted(country_whitelist) ) ) assert ( number_type is None or number_type in phonenumbers.PhoneNumberType.values() ), 'Invalid number type: {}'.format(number_type)
[docs] self.country = country
[docs] self.country_whitelist = country_whitelist
[docs] self.number_type = number_type
[docs] def __call__(self, form: Form, field: Field) -> None: if not field.data: return try: number = phonenumbers.parse(field.data, self.country) except phonenumbers.NumberParseException as exception: if exception.error_type == exception.INVALID_COUNTRY_CODE: raise ValidationError(self.invalid_country_code) from exception raise ValidationError(self.invalid_phone_number) from exception except Exception as exception: raise ValidationError(self.invalid_phone_number) from exception reason = phonenumbers.is_possible_number_with_reason(number) if reason != phonenumbers.ValidationResult.IS_POSSIBLE: raise ValidationError( self.length_errors.get(reason, self.invalid_phone_number) ) if not phonenumbers.is_valid_number(number): raise ValidationError(self.unknown_number) region = phonenumbers.region_code_for_number(number) if self.country_whitelist: if region not in self.country_whitelist: raise ValidationError(_( self.unsupported_country, mapping={ 'countries': ', '.join(sorted(self.country_whitelist)) } )) if self.number_type is None: return # skip if the region can't distinguish the requested type if region is None or self.number_type not in ( phonenumbers.supported_types_for_region(region) ): return number_type = phonenumbers.number_type(number) if number_type == phonenumbers.PhoneNumberType.FIXED_LINE_OR_MOBILE: # some countries (e.g. the US) can't tell fixed line from mobile, # so accept the aggregate type for either request if self.number_type in ( phonenumbers.PhoneNumberType.FIXED_LINE, phonenumbers.PhoneNumberType.MOBILE, ): number_type = self.number_type if number_type != self.number_type: raise ValidationError(self.type_required_errors.get( self.number_type, self.invalid_phone_number ))
[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.')
def __init__(self) -> None:
[docs] self.stdnum_validator = Stdnum(format='ch.ssn')
[docs] def __call__(self, form: Form, field: Field) -> None: if not field.data: return try: self.stdnum_validator(form, field) except ValidationError: raise ValidationError(self.message) from None
[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[DeclarativeBase]):
[docs] self.table = table
[docs] def __call__(self, form: Form, field: Field) -> None: if field.name not in self.table.__table__.columns: 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 InputRequiredIf(InputRequired): """ Validator which makes a field required if another field is set and has the given value. """ def __init__( self, field_name: str, field_data: object, message: str | None = None ):
[docs] self.field_name = field_name
[docs] self.field_data = field_data
[docs] self.message = message
[docs] def __call__(self, form: BaseForm, field: Field) -> None: if self.field_name not in form: raise RuntimeError(f"No field named '{self.field_name}' in form") field_data = form[self.field_name].data filter_data = self.field_data if ( field_data is None or filter_data is None or isinstance(field_data, bool) or isinstance(filter_data, bool) ): required = field_data is filter_data elif isinstance(filter_data, str) and filter_data.startswith('!'): required = field_data != filter_data[1:] else: required = field_data == filter_data if required: super().__call__(form, field) else: Optional().__call__(form, field)
[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 ):
[docs] self.min = min
[docs] self.max = max
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) )
[docs] class ValidHostname(HostnameValidation): """ Makes sure the given input is a valid hostname. Expects an :class:`wtforms.StringField` instance. """
[docs] message = _('Not a valid domain.')
[docs] def __call__(self, form: Form, field: Field) -> None: # type: ignore[override] if not field.data: return if not super().__call__(field.data): raise ValidationError(self.message)