From 9dbb25e382ff35cb852fc58d1a79bd4ce0b586ba Mon Sep 17 00:00:00 2001 From: Jonas Kittner Date: Wed, 24 May 2023 19:20:37 +0200 Subject: [PATCH] implement DateRange Validator similar to NumberRange --- docs/validators.rst | 42 ++++++++ src/wtforms/validators.py | 108 ++++++++++++++++++++ tests/validators/test_date_range.py | 151 ++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 tests/validators/test_date_range.py diff --git a/docs/validators.rst b/docs/validators.rst index 336bc9f8f..d1898acbf 100644 --- a/docs/validators.rst +++ b/docs/validators.rst @@ -57,6 +57,48 @@ Built-in validators .. autoclass:: wtforms.validators.NumberRange +.. autoclass:: wtforms.validators.DateRange + + This validator can be used with a custom callback to make it somewhat dynamic:: + + from datetime import date + from datetime import datetime + from datetime import timedelta + from functools import partial + + from wtforms import Form + from wtforms.fields import DateField + from wtforms.fields import DateTimeLocalField + from wtforms.validators import DateRange + + + def in_n_days(days): + return datetime.now() + timedelta(days=days) + + + cb = partial(in_n_days, 5) + + + class DateForm(Form): + date = DateField("date", [DateRange(min=date(2023, 1, 1), max_callback=cb)]) + datetime = DateTimeLocalField( + "datetime-local", + [ + DateRange( + min=datetime(2023, 1, 1, 15, 30), + max_callback=cb, + input_type="datetime-local", + ) + ], + ) + + In the example, we use the DateRange validator to prevent a date outside of a + specified range. for the field ``date`` we set the minimum range statically, + but the date must not be newer than the current time + 5 days. For the field + ``datetime`` we do the same, but specify an input_type to achieve the correct + formatting for the corresponding field type. + + .. autoclass:: wtforms.validators.Optional This also sets the ``optional`` :attr:`flag ` on diff --git a/src/wtforms/validators.py b/src/wtforms/validators.py index 3536963bf..e9b9d2b35 100644 --- a/src/wtforms/validators.py +++ b/src/wtforms/validators.py @@ -2,6 +2,8 @@ import math import re import uuid +from datetime import date +from datetime import datetime __all__ = ( "DataRequired", @@ -17,6 +19,7 @@ "Length", "length", "NumberRange", + "DateRange", "number_range", "Optional", "optional", @@ -224,6 +227,110 @@ def __call__(self, form, field): raise ValidationError(message % dict(min=self.min, max=self.max)) +class DateRange: + """ + Validates that a date or datetime is of a minimum and/or maximum value, + inclusive. This will work with dates and datetimes. + + :param min: + The minimum required date or datetime. If not provided, minimum + date or datetime will not be checked. + :param max: + The maximum date or datetime. If not provided, maximum date or datetime + will not be checked. + :param message: + Error message to raise in case of a validation error. Can be + interpolated using `%(min)s` and `%(max)s` if desired. Useful defaults + are provided depending on the existence of min and max. + :param input_type: + The type of field to check. Either ``datetime-local`` or ``date``. If + ``datetime-local`` the attributes (``min``, ``max``) are set using the + ``YYYY-MM-DDThh:mm`` format, if ``date`` (the default), ``yyyy-mm-dd`` + is used. + :param min_callback: + dynamically set the minimum date or datetime based on the return value of + a function. The specified function must not take any arguments. + :param max_callback: + dynamically set the maximum date or datetime based on the return value of + a function. The specified function must not take any arguments. + + When supported, sets the `min` and `max` attributes on widgets. + """ + + def __init__( + self, + min=None, + max=None, + message=None, + input_type="date", + min_callback=None, + max_callback=None, + ): + if min and min_callback: + raise ValueError("You can only specify one of min or min_callback.") + + if max and max_callback: + raise ValueError("You can only specify one of max or max_callback.") + + if input_type not in ("datetime-local", "date"): + raise ValueError( + f"Only datetime-local or date are allowed, not {input_type!r}" + ) + + self.min = min + self.max = max + self.message = message + self.min_callback = min_callback + self.max_callback = max_callback + self.field_flags = {} + if input_type == "date": + fmt = "%Y-%m-%d" + else: + fmt = "%Y-%m-%dT%H:%M" + + if self.min is not None: + self.field_flags["min"] = self.min.strftime(fmt) + if self.max is not None: + self.field_flags["max"] = self.max.strftime(fmt) + + def __call__(self, form, field): + if self.min_callback is not None: + self.min = self.min_callback() + + if self.max_callback is not None: + self.max = self.max_callback() + + if isinstance(self.min, date): + self.min = datetime(*self.min.timetuple()[:5]) + + if isinstance(self.max, date): + self.max = datetime(*self.max.timetuple()[:5]) + + data = field.data + if data is not None: + if isinstance(data, date): + data = datetime(*data.timetuple()[:5]) + + if (self.min is None or data >= self.min) and ( + self.max is None or data <= self.max + ): + return + + if self.message is not None: + message = self.message + + elif self.max is None: + message = field.gettext("Date must be at least %(min)s.") + + elif self.min is None: + message = field.gettext("Date must be at most %(max)s.") + + else: + message = field.gettext("Date must be between %(min)s and %(max)s.") + + raise ValidationError(message % dict(min=self.min, max=self.max)) + + class Optional: """ Allows empty input and stops the validation chain from continuing. @@ -723,6 +830,7 @@ def __call__(self, form, field): mac_address = MacAddress length = Length number_range = NumberRange +date_range = DateRange optional = Optional input_required = InputRequired data_required = DataRequired diff --git a/tests/validators/test_date_range.py b/tests/validators/test_date_range.py new file mode 100644 index 000000000..60fd0bfaa --- /dev/null +++ b/tests/validators/test_date_range.py @@ -0,0 +1,151 @@ +from datetime import date +from datetime import datetime + +import pytest + +from wtforms.validators import DateRange +from wtforms.validators import ValidationError + + +@pytest.mark.parametrize( + ("min_v", "max_v", "test_v"), + ( + (datetime(2023, 5, 23, 18), datetime(2023, 5, 25), date(2023, 5, 24)), + (date(2023, 5, 24), datetime(2023, 5, 25), datetime(2023, 5, 24, 15)), + (datetime(2023, 5, 24), None, date(2023, 5, 25)), + (None, datetime(2023, 5, 25), datetime(2023, 5, 24)), + ), +) +def test_date_range_passes(min_v, max_v, test_v, dummy_form, dummy_field): + """ + It should pass if the test_v is between min_v and max_v + """ + dummy_field.data = test_v + validator = DateRange(min_v, max_v) + validator(dummy_form, dummy_field) + + +@pytest.mark.parametrize( + ("min_v", "max_v", "test_v"), + ( + (date(2023, 5, 24), date(2023, 5, 25), None), + (datetime(2023, 5, 24, 18, 3), date(2023, 5, 25), None), + (datetime(2023, 5, 24), datetime(2023, 5, 25), None), + (datetime(2023, 5, 24), datetime(2023, 5, 25), datetime(2023, 5, 20)), + (datetime(2023, 5, 24), datetime(2023, 5, 25), datetime(2023, 5, 26)), + (datetime(2023, 5, 24), None, datetime(2023, 5, 23)), + (None, datetime(2023, 5, 25), datetime(2023, 5, 26)), + ), +) +def test_date_range_raises(min_v, max_v, test_v, dummy_form, dummy_field): + """ + It should raise ValidationError if the test_v is not between min_v and max_v + """ + dummy_field.data = test_v + validator = DateRange(min_v, max_v) + with pytest.raises(ValidationError): + validator(dummy_form, dummy_field) + + +@pytest.mark.parametrize( + ("min_v", "max_v", "min_flag", "max_flag"), + ( + (datetime(2023, 5, 24), datetime(2023, 5, 25), "2023-05-24", "2023-05-25"), + (None, datetime(2023, 5, 25), None, "2023-05-25"), + (datetime(2023, 5, 24), None, "2023-05-24", None), + ), +) +def test_date_range_field_flags_are_set_date(min_v, max_v, min_flag, max_flag): + """ + It should format the min and max attribute as yyyy-mm-dd + when input_type is ``date`` (default) + """ + validator = DateRange(min_v, max_v) + assert validator.field_flags.get("min") == min_flag + assert validator.field_flags.get("max") == max_flag + + +@pytest.mark.parametrize( + ("min_v", "max_v", "min_flag", "max_flag"), + ( + (date(2023, 5, 24), date(2023, 5, 25), "2023-05-24T00:00", "2023-05-25T00:00"), + (None, date(2023, 5, 25), None, "2023-05-25T00:00"), + (date(2023, 5, 24), None, "2023-05-24T00:00", None), + ), +) +def test_date_range_field_flags_are_set_datetime(min_v, max_v, min_flag, max_flag): + """ + It should format the min and max attribute as YYYY-MM-DDThh:mm + when input_type is ``datetime-local`` (default) + """ + validator = DateRange(min_v, max_v, input_type="datetime-local") + assert validator.field_flags.get("min") == min_flag + assert validator.field_flags.get("max") == max_flag + + +def test_date_range_input_type_invalid(): + """ + It should raise if the input_type is not either datetime-local or date + """ + with pytest.raises(ValueError) as exc_info: + DateRange(input_type="foo") + + (err_msg,) = exc_info.value.args + assert err_msg == "Only datetime-local or date are allowed, not 'foo'" + + +def _dt_callback_min(): + return datetime(2023, 5, 24, 15, 3) + + +def _d_callback_min(): + return date(2023, 5, 24) + + +def _dt_callback_max(): + return datetime(2023, 5, 25, 0, 3) + + +def _d_callback_max(): + return date(2023, 5, 25) + + +@pytest.mark.parametrize( + ("min_v", "max_v", "test_v"), + ( + (_dt_callback_min, _dt_callback_max, datetime(2023, 5, 24, 15, 4)), + (_d_callback_min, _d_callback_max, datetime(2023, 5, 24, 15, 4)), + (_dt_callback_min, None, datetime(2023, 5, 24, 15, 4)), + (None, _dt_callback_max, datetime(2023, 5, 24, 15, 2)), + (None, _dt_callback_max, date(2023, 5, 24)), + ), +) +def test_date_range_passes_with_callback(min_v, max_v, test_v, dummy_form, dummy_field): + """ + It should pass with a callback set as either min or max + """ + dummy_field.data = test_v + validator = DateRange(min_callback=min_v, max_callback=max_v) + validator(dummy_form, dummy_field) + + +def test_date_range_min_callback_and_value_set(): + """ + It should raise if both, a value and a callback are set for min + """ + with pytest.raises(ValueError) as exc_info: + DateRange(min=date(2023, 5, 24), min_callback=_dt_callback_min) + + (err_msg,) = exc_info.value.args + assert err_msg == "You can only specify one of min or min_callback." + + +def test_date_range_max_callback_and_value_set(): + """ + It should raise if both, a value and a callback are set for max + """ + with pytest.raises(ValueError) as exc_info: + DateRange(max=date(2023, 5, 24), max_callback=_dt_callback_max) + + (err_msg,) = exc_info.value.args + assert err_msg == "You can only specify one of max or max_callback."