Skip to content

Commit a101e4b

Browse files
committed
PPHA-262: Add imperial height form
1 parent 1bb1a99 commit a101e4b

File tree

15 files changed

+345
-36
lines changed

15 files changed

+345
-36
lines changed

lung_cancer_screening/core/form_fields.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,61 @@ def __init__(
369369
self.label_classes = label_classes
370370

371371
super().__init__(*args, **kwargs)
372+
373+
374+
class ImperialHeightWidget(widgets.MultiWidget):
375+
"""
376+
A widget that splits height into feet and inches inputs.
377+
"""
378+
379+
def __init__(self, attrs=None):
380+
height_widgets = (
381+
widgets.NumberInput(attrs=attrs),
382+
widgets.NumberInput(attrs=attrs),
383+
)
384+
super().__init__(height_widgets, attrs)
385+
386+
def decompress(self, value):
387+
"""
388+
Convert total inches back to feet and inches for display.
389+
"""
390+
if value:
391+
feet = int(value // 12)
392+
inches = value % 12
393+
return [feet, inches]
394+
return [None, None]
395+
396+
def subwidgets(self, name, value, attrs=None):
397+
"""
398+
Expose data for each subwidget, so that we can render them separately in the template.
399+
"""
400+
context = self.get_context(name, value, attrs)
401+
for subwidget in context["widget"]["subwidgets"]:
402+
yield subwidget
403+
404+
405+
class ImperialHeightField(forms.MultiValueField):
406+
"""
407+
A field that combines feet and inches into a single height value in cm.
408+
"""
409+
410+
widget = ImperialHeightWidget
411+
412+
def __init__(self, *args, **kwargs):
413+
fields = (
414+
IntegerField(label="Feet"),
415+
IntegerField(label="Inches"),
416+
)
417+
kwargs["template_name"] = "forms/imperial-height-input.jinja"
418+
419+
super().__init__(fields, *args, **kwargs)
420+
421+
def compress(self, data_list):
422+
"""
423+
Convert feet and inches to total inches.
424+
"""
425+
if data_list and all(data_list):
426+
feet, inches = data_list
427+
total_inches = feet * 12 + inches
428+
return int(total_inches)
429+
return None
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{% from 'nhsuk/components/input/macro.jinja' import input %}
2+
3+
{% if field.errors %}
4+
{% set error_message = {"text": field.errors | first} %}
5+
{% endif %}
6+
{% set unbound_field = field.field %}
7+
{% set hint = unbound_field.hint %}
8+
{{ input({
9+
"label": {
10+
"text": "Feet",
11+
"classes": "nhsuk-fieldset__legend--m",
12+
"isPageHeading": false
13+
},
14+
"hint": {
15+
"text": unbound_field.hint
16+
} if unbound_field.hint,
17+
"errorMessage": error_message,
18+
"id": field.auto_id,
19+
"name": field.html_name + "_0",
20+
"value": field.subwidgets.0.data.value,
21+
"classes": "nhsuk-input--width-2",
22+
"type": "number"
23+
}) }}
24+
25+
{{ input({
26+
"label": {
27+
"text": "Inches",
28+
"classes": "nhsuk-fieldset__legend--m",
29+
"isPageHeading": false
30+
},
31+
"errorMessage": error_message,
32+
"id": field.auto_id + "_1",
33+
"name": field.html_name + "_1",
34+
"value": field.subwidgets.1.data.value,
35+
"classes": "nhsuk-input--width-2",
36+
"type": "number"
37+
}) }}

lung_cancer_screening/core/tests/acceptance/test_questionnaire.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def test_full_questionnaire_user_journey(self):
7070
expect(responses).to_contain_text("Have you ever smoked? Yes, I used to smoke regularly")
7171
expect(responses).to_contain_text(
7272
age.strftime("What is your date of birth? %Y-%m-%d"))
73-
expect(responses).to_contain_text(f"What is your height? {height}")
73+
expect(responses).to_contain_text(f"What is your height? {feet} feet {inches} inches")
7474

7575
page.click("text=Submit")
7676

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
11
import decimal
22
from django import forms
33

4-
from lung_cancer_screening.core.form_fields import DecimalField
4+
from lung_cancer_screening.core.form_fields import ImperialHeightField
55
from ..models.response_set import ResponseSet
66

7-
class MetricHeightForm(forms.ModelForm):
7+
class ImperialHeightForm(forms.ModelForm):
88

99
def __init__(self, *args, **kwargs):
1010
self.participant = kwargs.pop('participant')
1111
super().__init__(*args, **kwargs)
1212
self.instance.participant = self.participant
1313

14-
self.fields["height_feet"] = DecimalField(
15-
label="Feet",
16-
classes="nhsuk-input--width-4",
17-
)
18-
19-
self.fields["height_inches"] = DecimalField(
20-
label="Inches",
21-
classes="nhsuk-input--width-4",
14+
self.fields["height_imperial"] = ImperialHeightField(
15+
label="Height",
16+
required=True,
2217
)
2318

2419
def clean_height(self):
25-
data = self.cleaned_data['height_feet']*12 + self.cleaned_data['height_inches']
26-
return data*2.54
20+
return None
2721

2822
class Meta:
2923
model = ResponseSet
30-
fields = ['height']
24+
fields = ['height_imperial', 'height']

lung_cancer_screening/questions/forms/metric_height_form.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ def __init__(self, *args, **kwargs):
1919
def clean_height(self):
2020
return self.cleaned_data['height'] * 10
2121

22+
def clean_height_imperial(self):
23+
return None
24+
2225
class Meta:
2326
model = ResponseSet
24-
fields = ['height']
27+
fields = ['height', 'height_imperial']

lung_cancer_screening/questions/jinja2/height.jinja

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
{% block page_content %}
1616
<div class="nhsuk-grid-row">
1717
<div class="nhsuk-grid-column-two-thirds">
18-
<form action="{{ request.path }}" method="POST" novalidate>
18+
{% if unit %}
19+
{% set action_url = request.path + '?unit=' + unit %}
20+
{% else %}
21+
{% set action_url = request.path %}
22+
{% endif %}
23+
<form action="{{ action_url }}" method="POST" novalidate>
1924
{{ csrf_input }}
2025
<h1 class="nhsuk-heading-l">What is your height?</h1>
2126
<p>An accurate measurement is important.
@@ -28,9 +33,13 @@
2833
}
2934
}) %}
3035

31-
{{ form.height.as_field_group() }}
32-
33-
<a href="?unit={{ switch_to_unit }}">Switch to {{ switch_to_unit }}</a>
36+
{% if unit == "imperial" %}
37+
{{ form.height_imperial.as_field_group() }}
38+
{% else %}
39+
{{ form.height.as_field_group() }}
40+
{% endif %}
41+
42+
<a href="?unit={{ switch_to_unit }}">Switch to {{ switch_to_unit }}</a>
3443

3544
{% endcall %}
3645

@@ -40,4 +49,4 @@
4049
</form>
4150
</div>
4251
</div>
43-
{% endblock %}
52+
{% endblock %}

lung_cancer_screening/questions/jinja2/responses.jinja

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<ul class="responses">
1919
<li>Have you ever smoked? {{ response_set.get_have_you_ever_smoked_display() }}</li>
2020
<li>What is your date of birth? {{ response_set.date_of_birth }}</li>
21-
<li>What is your height? {{ response_set.height // 10 if response_set.height else ""}}</li>
21+
<li>What is your height? {{ response_set.formatted_height }}</li>
2222
</ul>
2323

2424
<form action="{{ request.path }}" method="post">
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.7 on 2025-10-17 13:52
2+
3+
import django.core.validators
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('questions', '0009_responseset_height_and_more'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='responseset',
16+
name='height_imperial',
17+
field=models.PositiveIntegerField(blank=True, null=True),
18+
),
19+
migrations.AlterField(
20+
model_name='responseset',
21+
name='height',
22+
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1397, message='Height must be between 139.7cm and 243.8 cm'), django.core.validators.MaxValueValidator(2438, message='Height must be between 139.7cm and 243.8 cm')]),
23+
),
24+
migrations.AddConstraint(
25+
model_name='responseset',
26+
constraint=models.UniqueConstraint(condition=models.Q(('submitted_at__isnull', True)), fields=('participant',), name='unique_unsubmitted_response_per_participant', violation_error_message='An unsubmitted response set already exists for this participant'),
27+
),
28+
]

lung_cancer_screening/questions/models/response_set.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.core.validators import MaxValueValidator, MinValueValidator
44
from dateutil.relativedelta import relativedelta
55
from django.utils import timezone
6+
from decimal import Decimal
67

78
from .base import BaseModel
89
from .participant import Participant
@@ -23,12 +24,20 @@ class ResponseSet(BaseModel):
2324
)
2425
date_of_birth = models.DateField(null=True, blank=True)
2526

27+
MAX_HEIGHT_METRIC = 2438
28+
MIN_HEIGHT_METRIC = 1397
29+
MAX_HEIGHT_IMPERIAL = 96
30+
MIN_HEIGHT_IMPERIAL = 55
2631
height = models.PositiveIntegerField(null=True, blank=True, validators=[
27-
MinValueValidator(1397, message="Height must be between 139.7cm and 243.8 cm"),
28-
MaxValueValidator(2438, message="Height must be between 139.7cm and 243.8 cm"),
32+
MinValueValidator(MIN_HEIGHT_METRIC, message="Height must be between 139.7cm and 243.8 cm"),
33+
MaxValueValidator(MAX_HEIGHT_METRIC, message="Height must be between 139.7cm and 243.8 cm"),
34+
])
35+
height_imperial = models.PositiveIntegerField(null=True, blank=True, validators=[
36+
MinValueValidator(
37+
MIN_HEIGHT_IMPERIAL, message="Height must be between 4 feet 7 inches and 8 feet"),
38+
MaxValueValidator(
39+
MAX_HEIGHT_IMPERIAL, message="Height must be between 4 feet 7 inches and 8 feet"),
2940
])
30-
31-
#height_type
3241

3342
submitted_at = models.DateTimeField(null=True, blank=True)
3443

@@ -52,3 +61,12 @@ def clean(self):
5261
raise ValidationError(
5362
"Responses have already been submitted for this participant"
5463
)
64+
65+
@property
66+
def formatted_height(self):
67+
if self.height:
68+
return f"{Decimal(self.height) / 10}cm"
69+
elif self.height_imperial:
70+
value = Decimal(self.height_imperial)
71+
return f"{value // 12} feet {value % 12} inches"
72+
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from django.test import TestCase
2+
3+
from ....models.participant import Participant
4+
from ....forms.imperial_height_form import ImperialHeightForm
5+
6+
7+
class TestImperialHeightForm(TestCase):
8+
def setUp(self):
9+
self.participant = Participant.objects.create(unique_id="1234567890")
10+
self.response_set = self.participant.responseset_set.create(
11+
height=1704
12+
)
13+
14+
def test_is_valid_with_valid_input(self):
15+
form = ImperialHeightForm(
16+
participant=self.participant,
17+
instance=self.response_set,
18+
data={
19+
"height_imperial_0": "5", # feet
20+
"height_imperial_1": "9" # inches
21+
}
22+
)
23+
24+
self.assertTrue(form.is_valid())
25+
26+
def test_converts_feet_and_inches_to_an_inches_integer(self):
27+
form = ImperialHeightForm(
28+
participant=self.participant,
29+
instance=self.response_set,
30+
data={
31+
"height_imperial_0": "5", # feet
32+
"height_imperial_1": "9" # inches
33+
}
34+
)
35+
36+
self.assertTrue(form.is_valid(), f"Form errors: {form.errors}")
37+
self.assertEqual(form.cleaned_data['height_imperial'], 69)
38+
39+
def test_setting_imperial_height_clears_height(self):
40+
height = "69"
41+
form = ImperialHeightForm(
42+
instance=self.response_set,
43+
participant=self.participant,
44+
data={
45+
"height_imperial_0": "5", # feet
46+
"height_imperial_1": "9" # inches
47+
}
48+
)
49+
form.save()
50+
self.assertEqual(self.response_set.height, None)
51+
52+
def test_is_invalid_with_missing_data(self):
53+
form = ImperialHeightForm(
54+
participant=self.participant,
55+
instance=self.response_set,
56+
data={
57+
"height_imperial_0": "5",
58+
# missing inches
59+
}
60+
)
61+
self.assertFalse(form.is_valid())

0 commit comments

Comments
 (0)