From eba4e275a71f7eb9352445d9c4e62f0e5192b452 Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Wed, 6 Jun 2018 15:26:46 +0100 Subject: [PATCH] Add a DateField for WTForms When using WTForms there is currently no default way to validate the date fields provided by the frontend toolkit. This commit provides a new `DateField` class that can be used with WTForms to intrepret and validate data from one of these fields correctly. --- dmutils/__init__.py | 2 +- dmutils/forms/fields.py | 103 +++++++++++++- dmutils/forms/validators.py | 64 ++++++++- tests/test_date_field.py | 260 ++++++++++++++++++++++++++++++++++++ 4 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 tests/test_date_field.py diff --git a/dmutils/__init__.py b/dmutils/__init__.py index cae5db76..0da0230b 100644 --- a/dmutils/__init__.py +++ b/dmutils/__init__.py @@ -4,4 +4,4 @@ import flask_featureflags # noqa -__version__ = '38.2.0' +__version__ = '38.3.0' diff --git a/dmutils/forms/fields.py b/dmutils/forms/fields.py index 8d606531..2eeb3664 100644 --- a/dmutils/forms/fields.py +++ b/dmutils/forms/fields.py @@ -1,6 +1,13 @@ +''' +Fields for WTForms that are used within Digital Marketplace apps +''' + +import datetime from itertools import chain -from wtforms import StringField +from wtforms import Field, Form, FormField +from wtforms.fields import IntegerField, StringField +from wtforms.utils import unset_value from wtforms.validators import Length from .filters import strip_whitespace @@ -28,3 +35,97 @@ def __init__(self, label=None, **kwargs): ), )) super(EmailField, self).__init__(label, **kwargs) + + +class DateField(Field): + ''' + A date field(set) that uses a day, month and year field. + + The data is converted to a `datetime.date`. The year is + required to be four digits. + + It behaves like a WTForms.FieldForm, but it can be used + with validators like a normal WTForms.Field. + + >>> from wtforms import Form + >>> from wtforms.validators import DataRequired + >>> from werkzeug.datastructures import MultiDict + >>> formdata = MultiDict({ + ... 'date-day': '31', + ... 'date-month': '12', + ... 'date-year': '1999'}) + >>> class DateForm(Form): + ... date = DateField(validators=[DataRequired()]) + >>> form = DateForm(formdata) + >>> form.date.data + datetime.date(1999, 12, 31) + ''' + + # An internal class that defines the fields that make up the DateField. + # + # Inheriting from wtforms.FormField has limitations on using validators. + # + # Instead, the DateField is composed of a wtforms.FormField that is used + # to turn the form data into integer values, and we then grab the data. + # + # The FormField instance is based on this class. + class _DateForm(Form): + day = IntegerField("Day") + month = IntegerField("Month") + year = IntegerField("Year") + + def __init__(self, label=None, validators=None, separator='-', **kwargs): + super().__init__(label=label, validators=validators, **kwargs) + self.form_field = FormField(self._DateForm, separator=separator, **kwargs) + + def _value(self): + ''' + Return the values that are used to display the form + + Overrides wtforms.Field._value(). + ''' + if self.raw_data: + return self.raw_data[0] + else: + return {} + + def process(self, formdata, data=unset_value): + ''' + Process incoming data. + + Overrides wtforms.Field.process(). + + Filters, process_data and process_formdata are not supported. + ''' + self.process_errors = [] + + # use the FormField to process `formdata` and `data` + self.form_field.process(formdata, data) + + # make a "fake" raw_data property from the FormField values + # we need the raw_data property for various validators + raw_data = {field.name: field.raw_data[0] for field in self.form_field + if field.raw_data} + if not any(raw_data.values()): + # if all fields were empty we want raw_data to be None-ish + raw_data = {} + + # the WTForms.Field api expects .raw_data to be a list + self.raw_data = [raw_data] + + try: + self.data = datetime.date(**self.form_field.data) + except (TypeError, ValueError): + self.data = None + self.process_errors.append(self.gettext('Not a valid date value')) + + # if the year does not have four digits call the data invalid + if self.data: + try: + digits = len(self.form_field.year.raw_data[0]) + except IndexError: + digits = 0 + + if not digits == 4: + self.data = None + self.process_errors.append(self.gettext('Not a valid date value')) diff --git a/dmutils/forms/validators.py b/dmutils/forms/validators.py index 68e9e734..47fe8ab8 100644 --- a/dmutils/forms/validators.py +++ b/dmutils/forms/validators.py @@ -1,7 +1,14 @@ +''' +Validators for WTForms used in the Digital Marketplace frontend + +EmailValidator -- validate that a string is a valid email address +GreaterThan -- compare the values of two fields +FourDigitYear -- validate that a four digit year value is provided +''' import re -from wtforms.validators import Regexp +from wtforms.validators import Regexp, ValidationError class EmailValidator(Regexp): @@ -10,3 +17,58 @@ class EmailValidator(Regexp): def __init__(self, **kwargs): kwargs.setdefault("message", "Please enter a valid email address.") super(EmailValidator, self).__init__(self._email_re, **kwargs) + + +class GreaterThan: + """ + Compares the values of two fields. + + :param fieldname: + The name of the other field to compare to. + + :param message: + Error message to raise in case of a validation error. + """ + def __init__(self, fieldname, message=None): + self.fieldname = fieldname + self.message = message + + def __call__(self, form, field): + try: + other = form[self.fieldname] + except KeyError: + raise ValidationError(field.gettext("Invalid field name '%s'." % self.fieldname)) + if other.data and not field.data > other.data: + d = { + 'other_label': hasattr(other, 'label') and other.label.text or self.fieldname, + 'other_name': self.fieldname + } + message = self.message + if message is None: + message = field.gettext('Field must be greater than %(other_name)s.') + + raise ValidationError(message % d) + + +class FourDigitYear: + """ + Validates that a `DateField`'s year field has a four digit year value. + + :param message: + Error message to raise in case of a validation error. + """ + def __init__(self, message=None): + self.message = message + + def __call__(self, form, field): + try: + digits = len(field.form_field.year.raw_data[0]) + except IndexError: + digits = 0 + + if not digits == 4: + message = self.message + if message is None: + message = field.gettext("Year must be YYYY.") + + raise ValidationError(message) diff --git a/tests/test_date_field.py b/tests/test_date_field.py new file mode 100644 index 00000000..f6afc751 --- /dev/null +++ b/tests/test_date_field.py @@ -0,0 +1,260 @@ + +import pytest + +from werkzeug.datastructures import MultiDict +from wtforms import Form +from wtforms.validators import DataRequired, InputRequired + +from dmutils.forms.fields import DateField +from dmutils.forms.validators import GreaterThan, FourDigitYear + +import datetime + + +class DateForm(Form): + date = DateField() + + +def _data_fromtuple(t, prefix='date'): + keys = ('year', 'month', 'day') + if prefix: + keys = [f'{prefix}-{k}' for k in keys] + return MultiDict(zip(keys, t)) + + +def _data_fromdate(date, prefix='date'): + t = date.isoformat().split('-') + return _data_fromtuple(t, prefix) + + +@pytest.fixture(params=( + ('2020', '12', '31'), + ('1999', '12', '01'), + ('2000', '01', '31'), + ('2999', '01', '01'), + ('2004', '2', '29'), + ('0002', '02', '02'), + ('0999', '01', '01'), +)) +def valid_data(request): + return _data_fromtuple(request.param) + + +@pytest.fixture(params=( + _data_fromtuple(('year', 'month', 'day')), + _data_fromtuple(('2020', '12', 'day')), + _data_fromtuple(('2020', 'month', '31')), + _data_fromtuple(('year', '12', '31')), + _data_fromtuple(('2020', '2', '31')), + _data_fromtuple(('31', '1', '2020')), + _data_fromtuple(('2017', '2', '29')), + _data_fromtuple(('2020', '2', '31')), + _data_fromtuple(('1992', '', '26')), + _data_fromtuple(('2020', '1', '')), + _data_fromtuple(('', '01', '26')), +)) +def invalid_data(request): + return request.param + + +def test_create_date_field_with_validators(): + assert DateField(validators=[InputRequired(), DataRequired()]) + + +def test_date_field_has__value_method(): + assert DateField._value + + +def test_date_field__value_method_returns_raw_data(invalid_data): + form = DateForm(invalid_data) + form.validate() + assert form.date._value() == invalid_data.to_dict() + + +def test_date_field_data_is_date(valid_data): + form = DateForm(valid_data) + form.validate() + assert isinstance(form.date.data, datetime.date) + + +def test_date_field_data_is_none_for_invalid_data(invalid_data): + form = DateForm(invalid_data) + form.validate() + assert form.date.data is None + + +def test_valid_date_has_no_errors(valid_data): + form = DateForm(valid_data) + assert form.validate() + assert form.errors == {} + + +def test_invalid_data_has_errors(invalid_data): + form = DateForm(invalid_data) + assert not form.validate() + assert form.errors + + +def test_field_errors_is_list_of_strings(invalid_data): + form = DateForm(invalid_data) + form.validate() + assert isinstance(form.date.errors, list) + + def isstr(x): + return isinstance(x, str) and len(x) > 1 + + assert all(map(isstr, form.date.errors)) + + +def test_field_errors_before_validation_is_empty_collection(): + form = DateForm() + assert len(form.date.errors) == 0 + + +def test_field__value_before_input_is_empty_dict(): + form = DateForm() + assert form.date._value() == {} + + +@pytest.mark.parametrize('message', ( + ('Not a valid date value'), + ('You must answer this question with a valid date.'), +)) +def test_data_required_error_message_matches_validation_message(invalid_data, message): + class DateForm(Form): + date = DateField(validators=[DataRequired(message)]) + + form = DateForm(invalid_data) + form.validate() + assert form.date.errors[0] == message + + +@pytest.mark.parametrize('message', ( + ('This field is required.'), + ('You must answer this question.'), +)) +@pytest.mark.parametrize('empty', ( + _data_fromtuple(('', '', ''), prefix='invalid_prefix'), + _data_fromtuple(('', '', '')), + {}, +)) +def test_input_required_error_message_matches_validation_message(message, empty): + class DateForm(Form): + date = DateField(validators=[InputRequired(message)]) + + empty = MultiDict(empty) + form = DateForm(empty) + form.validate() + assert form.date.errors[0] == message + + +@pytest.mark.parametrize('message', ( + ('Not a valid date value'), + ('You must answer this question with a valid date.'), +)) +def test_error_message_with_multiple_validators(invalid_data, message): + class DateForm(Form): + date = DateField(validators=[InputRequired(), DataRequired(message)]) + + form = DateForm(invalid_data) + form.validate() + assert form.date.errors[0] == message + + +def test_date_field_with_input_required_validator(valid_data): + class DateForm(Form): + date = DateField(validators=[InputRequired()]) + + form = DateForm(valid_data) + assert form.validate() + + +def test_date_field_with_data_required_validator(valid_data): + class DateForm(Form): + date = DateField(validators=[DataRequired()]) + + form = DateForm(valid_data) + assert form.validate() + + +@pytest.mark.parametrize('timedelta', ( + (datetime.timedelta()), + (datetime.timedelta(days=1)), + (datetime.timedelta(weeks=4)), + (datetime.timedelta(days=30)), + (datetime.timedelta(weeks=52)), + (datetime.timedelta(days=365)), +)) +def test_date_field_with_greater_than_validator(valid_data, timedelta): + class DateForm(Form): + past = DateField() + date = DateField(validators=[GreaterThan('past')]) + + # test validator being triggered + future = DateForm(valid_data).date.data + timedelta + future_data = _data_fromdate(future, 'past') + invalid_data = valid_data.copy() + invalid_data.update(future_data) + + form = DateForm(invalid_data) + assert form.validate() is False + assert form.errors + + if not timedelta: + return + + # test validator not being triggered + past = DateForm(valid_data).date.data - timedelta + past_data = _data_fromdate(past, 'past') + valid_data.update(past_data) + + form = DateForm(valid_data) + assert form.validate() + + +def test_date_field_with_greater_than_validator_missing_past(valid_data): + class DateForm(Form): + past = DateField() + date = DateField(validators=[GreaterThan('invalid_key')]) + + form = DateForm(valid_data) + assert not form.validate() + assert form.date.errors + + +def test_date_field_with_greater_than_validator_key_error(valid_data): + class DateForm(Form): + past = DateField() + date = DateField(validators=[GreaterThan('invalid_key')]) + + future = DateForm(valid_data).date.data - datetime.timedelta(days=1) + future_data = _data_fromdate(future, 'past') + valid_data.update(future_data) + + form = DateForm(valid_data) + assert not form.validate() + assert form.errors + + +@pytest.mark.parametrize('two_digit_date', ( + ('00', '01', '01'), + ('99', '12', '31'), + ('20', '06', '26'), +)) +def test_date_form_with_y2k_validator(two_digit_date): + class DateForm(Form): + date = DateField(validators=[FourDigitYear()]) + + invalid_data = _data_fromtuple(two_digit_date) + form = DateForm(invalid_data) + assert not form.validate() + assert form.errors + + +def test_date_form_with_y2k_validator_accepts_four_digit_dates(valid_data): + class DateForm(Form): + date = DateField(validators=[FourDigitYear()]) + + form = DateForm(valid_data) + assert form.validate() + assert not form.errors