Skip to content

Commit b77b01b

Browse files
committed
implement DateRange Validator similar to NumberRange
1 parent e737a6c commit b77b01b

File tree

3 files changed

+301
-0
lines changed

3 files changed

+301
-0
lines changed

docs/validators.rst

+42
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,48 @@ Built-in validators
5757

5858
.. autoclass:: wtforms.validators.NumberRange
5959

60+
.. autoclass:: wtforms.validators.DateRange
61+
62+
This validator can be used with a custom callback to make it somewhat dynamic::
63+
64+
from datetime import date
65+
from datetime import datetime
66+
from datetime import timedelta
67+
from functools import partial
68+
69+
from wtforms import Form
70+
from wtforms.fields import DateField
71+
from wtforms.fields import DateTimeLocalField
72+
from wtforms.validators import DateRange
73+
74+
75+
def in_n_days(days):
76+
return datetime.now() + timedelta(days=days)
77+
78+
79+
cb = partial(in_n_days, 5)
80+
81+
82+
class DateForm(Form):
83+
date = DateField("date", [DateRange(min=date(2023, 1, 1), max_callback=cb)])
84+
datetime = DateTimeLocalField(
85+
"datetime-local",
86+
[
87+
DateRange(
88+
min=datetime(2023, 1, 1, 15, 30),
89+
max_callback=cb,
90+
input_type="datetime-local",
91+
)
92+
],
93+
)
94+
95+
In the example, we use the DateRange validator to prevent a date outside of a
96+
specified range. for the field ``date`` we set the minimum range statically,
97+
but the date must not be newer than the current time + 5 days. For the field
98+
``datetime`` we do the same, but specify an input_type to achieve the correct
99+
formatting for the corresponding field type.
100+
101+
60102
.. autoclass:: wtforms.validators.Optional
61103

62104
This also sets the ``optional`` :attr:`flag <wtforms.fields.Field.flags>` on

src/wtforms/validators.py

+108
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import math
33
import re
44
import uuid
5+
from datetime import date
6+
from datetime import datetime
57

68
__all__ = (
79
"DataRequired",
@@ -17,6 +19,7 @@
1719
"Length",
1820
"length",
1921
"NumberRange",
22+
"DateRange",
2023
"number_range",
2124
"Optional",
2225
"optional",
@@ -224,6 +227,110 @@ def __call__(self, form, field):
224227
raise ValidationError(message % dict(min=self.min, max=self.max))
225228

226229

230+
class DateRange:
231+
"""
232+
Validates that a date or datetime is of a minimum and/or maximum value,
233+
inclusive. This will work with dates and datetimes.
234+
235+
:param min:
236+
The minimum required date or datetime. If not provided, minimum
237+
date or datetime will not be checked.
238+
:param max:
239+
The maximum date or datetime. If not provided, maximum date or datetime
240+
will not be checked.
241+
:param message:
242+
Error message to raise in case of a validation error. Can be
243+
interpolated using `%(min)s` and `%(max)s` if desired. Useful defaults
244+
are provided depending on the existence of min and max.
245+
:param input_type:
246+
The type of field to check. Either ``datetime-local`` or ``date``. If
247+
``datetime-local`` the attributes (``min``, ``max``) are set using the
248+
``YYYY-MM-DDThh:mm`` format, if ``date`` (the default), ``yyyy-mm-dd``
249+
is used.
250+
:param min_callback:
251+
dynamically set the minimum date or datetime based on the return value of
252+
a function. The specified function must not take any arguments.
253+
:param max_callback:
254+
dynamically set the maximum date or datetime based on the return value of
255+
a function. The specified function must not take any arguments.
256+
257+
When supported, sets the `min` and `max` attributes on widgets.
258+
"""
259+
260+
def __init__(
261+
self,
262+
min=None,
263+
max=None,
264+
message=None,
265+
input_type="date",
266+
min_callback=None,
267+
max_callback=None,
268+
):
269+
if min and min_callback:
270+
raise ValueError("You can only specify one of min or min_callback.")
271+
272+
if max and max_callback:
273+
raise ValueError("You can only specify one of max or max_callback.")
274+
275+
if input_type not in ("datetime-local", "date"):
276+
raise ValueError(
277+
f"Only datetime-local or date are allowed, not {input_type!r}"
278+
)
279+
280+
self.min = min
281+
self.max = max
282+
self.message = message
283+
self.min_callback = min_callback
284+
self.max_callback = max_callback
285+
self.field_flags = {}
286+
if input_type == "date":
287+
fmt = "%Y-%m-%d"
288+
else:
289+
fmt = "%Y-%m-%dT%H:%M"
290+
291+
if self.min is not None:
292+
self.field_flags["min"] = self.min.strftime(fmt)
293+
if self.max is not None:
294+
self.field_flags["max"] = self.max.strftime(fmt)
295+
296+
def __call__(self, form, field):
297+
if self.min_callback is not None:
298+
self.min = self.min_callback()
299+
300+
if self.max_callback is not None:
301+
self.max = self.max_callback()
302+
303+
if isinstance(self.min, date):
304+
self.min = datetime(*self.min.timetuple()[:5])
305+
306+
if isinstance(self.max, date):
307+
self.max = datetime(*self.max.timetuple()[:5])
308+
309+
data = field.data
310+
if data is not None:
311+
if isinstance(data, date):
312+
data = datetime(*data.timetuple()[:5])
313+
314+
if (self.min is None or data >= self.min) and (
315+
self.max is None or data <= self.max
316+
):
317+
return
318+
319+
if self.message is not None:
320+
message = self.message
321+
322+
elif self.max is None:
323+
message = field.gettext("Date must be at least %(min)s.")
324+
325+
elif self.min is None:
326+
message = field.gettext("Date must be at most %(max)s.")
327+
328+
else:
329+
message = field.gettext("Date must be between %(min)s and %(max)s.")
330+
331+
raise ValidationError(message % dict(min=self.min, max=self.max))
332+
333+
227334
class Optional:
228335
"""
229336
Allows empty input and stops the validation chain from continuing.
@@ -721,6 +828,7 @@ def __call__(self, form, field):
721828
mac_address = MacAddress
722829
length = Length
723830
number_range = NumberRange
831+
date_range = DateRange
724832
optional = Optional
725833
input_required = InputRequired
726834
data_required = DataRequired

tests/validators/test_date_range.py

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from datetime import date
2+
from datetime import datetime
3+
4+
import pytest
5+
6+
from wtforms.validators import DateRange
7+
from wtforms.validators import ValidationError
8+
9+
10+
@pytest.mark.parametrize(
11+
("min_v", "max_v", "test_v"),
12+
(
13+
(datetime(2023, 5, 23, 18), datetime(2023, 5, 25), date(2023, 5, 24)),
14+
(date(2023, 5, 24), datetime(2023, 5, 25), datetime(2023, 5, 24, 15)),
15+
(datetime(2023, 5, 24), None, date(2023, 5, 25)),
16+
(None, datetime(2023, 5, 25), datetime(2023, 5, 24)),
17+
),
18+
)
19+
def test_date_range_passes(min_v, max_v, test_v, dummy_form, dummy_field):
20+
"""
21+
It should pass if the test_v is between min_v and max_v
22+
"""
23+
dummy_field.data = test_v
24+
validator = DateRange(min_v, max_v)
25+
validator(dummy_form, dummy_field)
26+
27+
28+
@pytest.mark.parametrize(
29+
("min_v", "max_v", "test_v"),
30+
(
31+
(date(2023, 5, 24), date(2023, 5, 25), None),
32+
(datetime(2023, 5, 24, 18, 3), date(2023, 5, 25), None),
33+
(datetime(2023, 5, 24), datetime(2023, 5, 25), None),
34+
(datetime(2023, 5, 24), datetime(2023, 5, 25), datetime(2023, 5, 20)),
35+
(datetime(2023, 5, 24), datetime(2023, 5, 25), datetime(2023, 5, 26)),
36+
(datetime(2023, 5, 24), None, datetime(2023, 5, 23)),
37+
(None, datetime(2023, 5, 25), datetime(2023, 5, 26)),
38+
),
39+
)
40+
def test_date_range_raises(min_v, max_v, test_v, dummy_form, dummy_field):
41+
"""
42+
It should raise ValidationError if the test_v is not between min_v and max_v
43+
"""
44+
dummy_field.data = test_v
45+
validator = DateRange(min_v, max_v)
46+
with pytest.raises(ValidationError):
47+
validator(dummy_form, dummy_field)
48+
49+
50+
@pytest.mark.parametrize(
51+
("min_v", "max_v", "min_flag", "max_flag"),
52+
(
53+
(datetime(2023, 5, 24), datetime(2023, 5, 25), "2023-05-24", "2023-05-25"),
54+
(None, datetime(2023, 5, 25), None, "2023-05-25"),
55+
(datetime(2023, 5, 24), None, "2023-05-24", None),
56+
),
57+
)
58+
def test_date_range_field_flags_are_set_date(min_v, max_v, min_flag, max_flag):
59+
"""
60+
It should format the min and max attribute as yyyy-mm-dd
61+
when input_type is ``date`` (default)
62+
"""
63+
validator = DateRange(min_v, max_v)
64+
assert validator.field_flags.get("min") == min_flag
65+
assert validator.field_flags.get("max") == max_flag
66+
67+
68+
@pytest.mark.parametrize(
69+
("min_v", "max_v", "min_flag", "max_flag"),
70+
(
71+
(date(2023, 5, 24), date(2023, 5, 25), "2023-05-24T00:00", "2023-05-25T00:00"),
72+
(None, date(2023, 5, 25), None, "2023-05-25T00:00"),
73+
(date(2023, 5, 24), None, "2023-05-24T00:00", None),
74+
),
75+
)
76+
def test_date_range_field_flags_are_set_datetime(min_v, max_v, min_flag, max_flag):
77+
"""
78+
It should format the min and max attribute as YYYY-MM-DDThh:mm
79+
when input_type is ``datetime-local`` (default)
80+
"""
81+
validator = DateRange(min_v, max_v, input_type="datetime-local")
82+
assert validator.field_flags.get("min") == min_flag
83+
assert validator.field_flags.get("max") == max_flag
84+
85+
86+
def test_date_range_input_type_invalid():
87+
"""
88+
It should raise if the input_type is not either datetime-local or date
89+
"""
90+
with pytest.raises(ValueError) as exc_info:
91+
DateRange(input_type="foo")
92+
93+
(err_msg,) = exc_info.value.args
94+
assert err_msg == "Only datetime-local or date are allowed, not 'foo'"
95+
96+
97+
def _dt_callback_min():
98+
return datetime(2023, 5, 24, 15, 3)
99+
100+
101+
def _d_callback_min():
102+
return date(2023, 5, 24)
103+
104+
105+
def _dt_callback_max():
106+
return datetime(2023, 5, 25, 0, 3)
107+
108+
109+
def _d_callback_max():
110+
return date(2023, 5, 25)
111+
112+
113+
@pytest.mark.parametrize(
114+
("min_v", "max_v", "test_v"),
115+
(
116+
(_dt_callback_min, _dt_callback_max, datetime(2023, 5, 24, 15, 4)),
117+
(_d_callback_min, _d_callback_max, datetime(2023, 5, 24, 15, 4)),
118+
(_dt_callback_min, None, datetime(2023, 5, 24, 15, 4)),
119+
(None, _dt_callback_max, datetime(2023, 5, 24, 15, 2)),
120+
(None, _dt_callback_max, date(2023, 5, 24)),
121+
),
122+
)
123+
def test_date_range_passes_with_callback(min_v, max_v, test_v, dummy_form, dummy_field):
124+
"""
125+
It should pass with a callback set as either min or max
126+
"""
127+
dummy_field.data = test_v
128+
validator = DateRange(min_callback=min_v, max_callback=max_v)
129+
validator(dummy_form, dummy_field)
130+
131+
132+
def test_date_range_min_callback_and_value_set():
133+
"""
134+
It should raise if both, a value and a callback are set for min
135+
"""
136+
with pytest.raises(ValueError) as exc_info:
137+
DateRange(min=date(2023, 5, 24), min_callback=_dt_callback_min)
138+
139+
(err_msg,) = exc_info.value.args
140+
assert err_msg == "You can only specify one of min or min_callback."
141+
142+
143+
def test_date_range_max_callback_and_value_set():
144+
"""
145+
It should raise if both, a value and a callback are set for max
146+
"""
147+
with pytest.raises(ValueError) as exc_info:
148+
DateRange(max=date(2023, 5, 24), max_callback=_dt_callback_max)
149+
150+
(err_msg,) = exc_info.value.args
151+
assert err_msg == "You can only specify one of max or max_callback."

0 commit comments

Comments
 (0)