Skip to content

Commit 9878891

Browse files
committed
Move Date of birth validation to a form
1 parent c4f90b9 commit 9878891

File tree

14 files changed

+1052
-51
lines changed

14 files changed

+1052
-51
lines changed
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import datetime
2+
3+
from django import forms
4+
from django.core import validators
5+
from django.forms import Textarea, ValidationError, widgets
6+
from django.utils.translation import gettext
7+
from django.utils.translation import gettext_lazy as _
8+
9+
from .utils.date_formatting import format_date
10+
11+
12+
class SplitDateWidget(widgets.MultiWidget):
13+
"""
14+
A widget that splits a date into 3 number inputs.
15+
Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/widgets.py
16+
"""
17+
18+
def __init__(self, attrs=None):
19+
date_widgets = (
20+
widgets.NumberInput(attrs=attrs),
21+
widgets.NumberInput(attrs=attrs),
22+
widgets.NumberInput(attrs=attrs),
23+
)
24+
super().__init__(date_widgets, attrs)
25+
26+
def decompress(self, value):
27+
if value:
28+
return [value.day, value.month, value.year]
29+
return [None, None, None]
30+
31+
def subwidgets(self, name, value, attrs=None):
32+
"""
33+
Expose data for each subwidget, so that we can render them separately in the template.
34+
35+
For some reason, as of Django 5.2, `MultiWidget` does not actually override the default
36+
implementation provided by `Widget`, which means you can't call `form.date.0` `form.date.1`
37+
to access the individual parts.
38+
(see https://stackoverflow.com/questions/24866936/render-only-one-part-of-a-multiwidget-in-django)
39+
"""
40+
context = self.get_context(name, value, attrs)
41+
for subwidget in context["widget"]["subwidgets"]:
42+
yield subwidget
43+
44+
45+
class SplitHiddenDateWidget(SplitDateWidget):
46+
"""
47+
A widget that splits a date into 3 number inputs (hidden variant)
48+
Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/widgets.py
49+
"""
50+
51+
def __init__(self, *args, **kwargs):
52+
super().__init__(*args, **kwargs)
53+
for widget in self.widgets:
54+
widget.input_type = "hidden"
55+
56+
57+
class SplitDateField(forms.MultiValueField):
58+
"""
59+
A form field that can be rendered as 3 inputs using the dateInput component in the design system.
60+
Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/fields.py
61+
"""
62+
63+
widget = SplitDateWidget
64+
hidden_widget = SplitHiddenDateWidget
65+
default_error_messages = {"invalid": _("Enter a valid date.")}
66+
67+
def __init__(self, *args, **kwargs):
68+
max_value = kwargs.pop("max_value", datetime.date.today())
69+
min_value = kwargs.pop("min_value", datetime.date(1900, 1, 1))
70+
self.hint = kwargs.pop("hint", None)
71+
72+
day_bounds_error = gettext("Day should be between 1 and 31.")
73+
month_bounds_error = gettext("Month should be between 1 and 12.")
74+
year_bounds_error = gettext(
75+
"Year should be between %(min_year)s and %(max_year)s."
76+
) % {"min_year": min_value.year, "max_year": max_value.year}
77+
78+
day_kwargs = {
79+
"min_value": 1,
80+
"max_value": 31,
81+
"error_messages": {
82+
"min_value": day_bounds_error,
83+
"max_value": day_bounds_error,
84+
"invalid": gettext("Enter day as a number."),
85+
},
86+
}
87+
month_kwargs = {
88+
"min_value": 1,
89+
"max_value": 12,
90+
"error_messages": {
91+
"min_value": month_bounds_error,
92+
"max_value": month_bounds_error,
93+
"invalid": gettext("Enter month as a number."),
94+
},
95+
}
96+
year_kwargs = {
97+
"min_value": min_value.year,
98+
"max_value": max_value.year,
99+
"error_messages": {
100+
"min_value": year_bounds_error,
101+
"max_value": year_bounds_error,
102+
"invalid": gettext("Enter year as a number."),
103+
},
104+
}
105+
106+
self.fields = [
107+
IntegerField(**day_kwargs),
108+
IntegerField(**month_kwargs),
109+
IntegerField(**year_kwargs),
110+
]
111+
112+
kwargs["template_name"] = "forms/date-input.jinja"
113+
114+
super().__init__(self.fields, *args, **kwargs)
115+
116+
self.validators.append(
117+
validators.MinValueValidator(
118+
min_value, f"Enter a date after {format_date(min_value)}"
119+
)
120+
)
121+
self.validators.append(
122+
validators.MaxValueValidator(
123+
max_value, f"Enter a date before {format_date(max_value)}"
124+
)
125+
)
126+
127+
def compress(self, data_list):
128+
if data_list:
129+
try:
130+
if any(item in self.empty_values for item in data_list):
131+
raise ValueError
132+
return datetime.date(data_list[2], data_list[1], data_list[0])
133+
except ValueError:
134+
raise ValidationError(
135+
self.error_messages["invalid"], code="invalid")
136+
return None
137+
138+
def widget_attrs(self, widget):
139+
attrs = super().widget_attrs(widget)
140+
if not isinstance(widget, SplitDateWidget):
141+
return attrs
142+
for subfield, subwidget in zip(self.fields, widget.widgets):
143+
if subfield.min_value is not None:
144+
subwidget.attrs["min"] = subfield.min_value
145+
if subfield.max_value is not None:
146+
subwidget.attrs["max"] = subfield.max_value
147+
return attrs
148+
149+
150+
class CharField(forms.CharField):
151+
def __init__(
152+
self,
153+
*args,
154+
hint=None,
155+
label_classes=None,
156+
classes=None,
157+
**kwargs,
158+
):
159+
widget = kwargs.get("widget")
160+
if (isinstance(widget, type) and widget is Textarea) or isinstance(
161+
widget, Textarea
162+
):
163+
kwargs["template_name"] = "forms/textarea.jinja"
164+
else:
165+
kwargs["template_name"] = "forms/input.jinja"
166+
167+
self.hint = hint
168+
self.classes = classes
169+
self.label_classes = label_classes
170+
171+
super().__init__(*args, **kwargs)
172+
173+
def widget_attrs(self, widget):
174+
attrs = super().widget_attrs(widget)
175+
176+
# Don't use maxlength even if there is a max length validator.
177+
# This attribute prevents the user from seeing errors, so we don't use it
178+
attrs.pop("maxlength", None)
179+
180+
return attrs
181+
182+
183+
class IntegerField(forms.IntegerField):
184+
def __init__(
185+
self,
186+
*args,
187+
hint=None,
188+
label_classes=None,
189+
classes=None,
190+
**kwargs,
191+
):
192+
kwargs["template_name"] = "forms/input.jinja"
193+
194+
self.hint = hint
195+
self.classes = classes
196+
self.label_classes = label_classes
197+
198+
super().__init__(*args, **kwargs)
199+
200+
def widget_attrs(self, widget):
201+
attrs = super().widget_attrs(widget)
202+
203+
# Don't use min/max/step attributes.
204+
attrs.pop("min", None)
205+
attrs.pop("max", None)
206+
attrs.pop("step", None)
207+
208+
return attrs
209+
210+
211+
class BoundChoiceField(forms.BoundField):
212+
"""
213+
Specialisation of BoundField that can deal with conditionally shown fields,
214+
and divider content between choices.
215+
This can be used to render a set of radios or checkboxes with text boxes to capture
216+
more details.
217+
"""
218+
219+
def __init__(self, form: forms.Form, field: "ChoiceField", name: str):
220+
super().__init__(form, field, name)
221+
222+
self._conditional_html = {}
223+
self.dividers = {}
224+
225+
def add_conditional_html(self, value, html):
226+
if isinstance(self.field.widget, widgets.Select):
227+
raise ValueError(
228+
"select comonent does not support conditional fields")
229+
230+
self._conditional_html[value] = html
231+
232+
def conditional_html(self, value):
233+
return self._conditional_html.get(value)
234+
235+
def add_divider_after(self, previous, divider):
236+
self.dividers[previous] = divider
237+
238+
def get_divider_after(self, previous):
239+
return self.dividers.get(previous)
240+
241+
242+
class ChoiceField(forms.ChoiceField):
243+
"""
244+
A ChoiceField that renders using NHS.UK design system radios/select
245+
components.
246+
"""
247+
248+
widget = widgets.RadioSelect
249+
bound_field_class = BoundChoiceField
250+
251+
def __init__(
252+
self,
253+
*args,
254+
hint=None,
255+
label_classes=None,
256+
classes=None,
257+
**kwargs,
258+
):
259+
kwargs["template_name"] = ChoiceField._template_name(
260+
kwargs.get("widget", self.widget)
261+
)
262+
263+
self.hint = hint
264+
self.classes = classes
265+
self.label_classes = label_classes
266+
267+
super().__init__(*args, **kwargs)
268+
269+
@staticmethod
270+
def _template_name(widget):
271+
if (isinstance(widget, type) and widget is widgets.RadioSelect) or isinstance(
272+
widget, widgets.RadioSelect
273+
):
274+
return "forms/radios.jinja"
275+
elif (isinstance(widget, type) and widget is widgets.Select) or isinstance(
276+
widget, widgets.Select
277+
):
278+
return "forms/select.jinja"
279+
280+
281+
class MultipleChoiceField(forms.MultipleChoiceField):
282+
"""
283+
A MultipleChoiceField that renders using the NHS.UK design system checkboxes
284+
component.
285+
"""
286+
287+
widget = widgets.CheckboxSelectMultiple
288+
bound_field_class = BoundChoiceField
289+
290+
def __init__(
291+
self,
292+
*args,
293+
hint=None,
294+
label_classes=None,
295+
classes=None,
296+
**kwargs,
297+
):
298+
kwargs["template_name"] = "forms/checkboxes.jinja"
299+
300+
self.hint = hint
301+
self.classes = classes
302+
self.label_classes = label_classes
303+
304+
super().__init__(*args, **kwargs)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{% from 'components/date-input/macro.jinja' import dateInput as dateInput %}
2+
{% if field.errors %}
3+
{% set error_message = {"text": field.errors | first} %}
4+
{% endif %}
5+
{% set unbound_field = field.field %}
6+
{% set hint = unbound_field.hint %}
7+
{{ dateInput({
8+
"name": field.html_name,
9+
"fieldset": {
10+
"legend": {
11+
"text": field.label,
12+
"classes": "nhsuk-fieldset__legend--m",
13+
"isPageHeading": false
14+
}
15+
} if field.label,
16+
"errorMessage": error_message,
17+
"hint": {
18+
"html": hint|e
19+
} if hint,
20+
"items": [
21+
{
22+
"name": field.subwidgets.0.data.name,
23+
"label": "Day",
24+
"classes": "nhsuk-input--width-2",
25+
"value": field.subwidgets.0.data.value,
26+
"id": field.auto_id
27+
},
28+
{
29+
"name": field.subwidgets.1.data.name,
30+
"label": "Month",
31+
"classes": "nhsuk-input--width-2",
32+
"value": field.subwidgets.1.data.value,
33+
"id": field.auto_id ~ "_1"
34+
},
35+
{
36+
"name": field.subwidgets.2.data.name,
37+
"label": "Year",
38+
"classes": "nhsuk-input--width-4",
39+
"value": field.subwidgets.2.data.value,
40+
"id": field.auto_id ~ "_2"
41+
}
42+
]
43+
}) }}

lung_cancer_screening/core/tests/acceptance/test_participant_out_of_age_range.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ def test_participant_out_of_age_range(self):
4747

4848
age = datetime.now() - relativedelta(years=20)
4949

50-
page.fill("input[name='day']", str(age.day))
51-
page.fill("input[name='month']", str(age.month))
52-
page.fill("input[name='year']", str(age.year))
50+
page.get_by_label("Day").fill(str(age.day))
51+
page.get_by_label("Month").fill(str(age.month))
52+
page.get_by_label("Year").fill(str(age.year))
5353

5454
page.click("text=Continue")
5555

lung_cancer_screening/core/tests/acceptance/test_questionnaire.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ def test_full_questionaire_user_journey(self):
4646

4747
age = datetime.now() - relativedelta(years=55)
4848

49-
page.fill("input[name='day']", str(age.day))
50-
page.fill("input[name='month']", str(age.month))
51-
page.fill("input[name='year']", str(age.year))
49+
page.get_by_label("Day").fill(str(age.day))
50+
page.get_by_label("Month").fill(str(age.month))
51+
page.get_by_label("Year").fill(str(age.year))
5252

5353
page.click("text=Continue")
5454

0 commit comments

Comments
 (0)