diff --git a/.gitignore b/.gitignore
index f388cc4a..e88632d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ __pycache__
lung_cancer_screening/assets/compiled/*
!lung_cancer_screening/assets/compiled/.gitkeep
.DS_Store
+.venv
diff --git a/.tool-versions b/.tool-versions
index a3d8f27c..f73cf3b5 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,5 +1,6 @@
# This file is for you! Please, updated to the versions agreed by your team.
+python 3.13.8
terraform 1.7.0
pre-commit 4.3.0
vale 3.6.0
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 423458c1..246a6ee8 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,5 +3,23 @@
"MD013": false,
"MD024": { "siblings_only": true },
"MD033": false
- }
+ },
+ "cSpell.words": [
+ "dateutil",
+ "endcall",
+ "endset",
+ "fieldset",
+ "gunicorn",
+ "jinja",
+ "makemigrations",
+ "nhsuk",
+ "novalidate",
+ "psycopg",
+ "relativedelta",
+ "responseset",
+ "stylesheet",
+ "toplevel",
+ "unsubmitted",
+ "whitenoise"
+ ]
}
diff --git a/Dockerfile b/Dockerfile
index a66f5c21..6accfb0e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,7 @@ FROM node:24.10.0-alpine3.21 AS asset_builder
WORKDIR /app
COPY package.json package-lock.json rollup.config.js ./
-COPY lung_cancer_screening ./lung_cancer_screening
+COPY . .
RUN npm ci
RUN npm run compile
diff --git a/docker-compose.yml b/docker-compose.yml
index c3b15b37..2764ce11 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,6 +13,7 @@ services:
- .env
depends_on:
- db
+ - asset_builder
volumes:
- ./:/app
- venv:/app/.venv
@@ -26,7 +27,8 @@ services:
target: asset_builder
command: npm run watch
volumes:
- - ./lung_cancer_screening/assets/compiled:/app/lung_cancer_screening/assets/compiled
+ - ./:/app
+ - node_modules:/app/node_modules
db:
image: postgres:15-alpine
@@ -48,3 +50,4 @@ volumes:
redis_data:
venv:
playwright_browsers:
+ node_modules:
diff --git a/lung_cancer_screening/assets/sass/components/_multi_field_input.scss b/lung_cancer_screening/assets/sass/components/_multi_field_input.scss
new file mode 100644
index 00000000..7705da0a
--- /dev/null
+++ b/lung_cancer_screening/assets/sass/components/_multi_field_input.scss
@@ -0,0 +1,5 @@
+.multi-field-input__item {
+ display: inline-block;
+ margin-bottom: 0;
+ margin-right: 24px;
+}
diff --git a/lung_cancer_screening/assets/sass/main.scss b/lung_cancer_screening/assets/sass/main.scss
index 26ef4fee..5efa3de7 100644
--- a/lung_cancer_screening/assets/sass/main.scss
+++ b/lung_cancer_screening/assets/sass/main.scss
@@ -2,4 +2,4 @@
@forward "nhsuk-frontend/dist/nhsuk";
// Components that are not in the NHS.UK frontend library
-// @import "components/button";
+@forward "components/multi_field_input";
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/core/form_fields.py
index 2b87c930..221a9294 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/core/form_fields.py
@@ -207,6 +207,33 @@ def widget_attrs(self, widget):
return attrs
+class DecimalField(forms.DecimalField):
+ def __init__(
+ self,
+ *args,
+ hint=None,
+ label_classes=None,
+ classes=None,
+ **kwargs,
+ ):
+ kwargs["template_name"] = "forms/input.jinja"
+
+ self.hint = hint
+ self.classes = classes
+ self.label_classes = label_classes
+
+ super().__init__(*args, **kwargs)
+
+ def widget_attrs(self, widget):
+ attrs = super().widget_attrs(widget)
+
+ # Don't use min/max/step attributes.
+ attrs.pop("min", None)
+ attrs.pop("max", None)
+ attrs.pop("step", None)
+
+ return attrs
+
class BoundChoiceField(forms.BoundField):
"""
@@ -225,7 +252,7 @@ def __init__(self, form: forms.Form, field: "ChoiceField", name: str):
def add_conditional_html(self, value, html):
if isinstance(self.field.widget, widgets.Select):
raise ValueError(
- "select comonent does not support conditional fields")
+ "select component does not support conditional fields")
self._conditional_html[value] = html
@@ -342,3 +369,75 @@ def __init__(
self.label_classes = label_classes
super().__init__(*args, **kwargs)
+
+
+class ImperialHeightWidget(widgets.MultiWidget):
+ """
+ A widget that splits height into feet and inches inputs.
+ """
+
+ def __init__(self, attrs=None):
+ height_widgets = (
+ widgets.NumberInput(attrs=attrs),
+ widgets.NumberInput(attrs=attrs),
+ )
+ super().__init__(height_widgets, attrs)
+
+ def decompress(self, value):
+ """
+ Convert total inches back to feet and inches for display.
+ """
+ if value:
+ feet = int(value // 12)
+ inches = value % 12
+ return [feet, inches]
+ return [None, None]
+
+ def subwidgets(self, name, value, attrs=None):
+ """
+ Expose data for each subwidget, so that we can render them separately in the template.
+ """
+ context = self.get_context(name, value, attrs)
+ for subwidget in context["widget"]["subwidgets"]:
+ yield subwidget
+
+
+class ImperialHeightField(forms.MultiValueField):
+ """
+ A field that combines feet and inches into a single height value in cm.
+ """
+
+ widget = ImperialHeightWidget
+
+ def __init__(self, *args, **kwargs):
+ error_messages = kwargs.get("error_messages", {})
+
+ feet_kwargs = {
+ "error_messages": {
+ 'invalid': 'Feet must be in whole numbers.',
+ **error_messages,
+ },
+ }
+ inches_kwargs = {
+ "error_messages": {
+ 'invalid': 'Inches must be in whole numbers.',
+ **error_messages,
+ },
+ }
+ fields = (
+ IntegerField(**feet_kwargs),
+ IntegerField(**inches_kwargs),
+ )
+ kwargs["template_name"] = "forms/imperial-height-input.jinja"
+
+ super().__init__(fields, *args, **kwargs)
+
+ def compress(self, data_list):
+ """
+ Convert feet and inches to total inches.
+ """
+ if data_list and all(data_list):
+ feet, inches = data_list
+ total_inches = feet * 12 + inches
+ return int(total_inches)
+ return None
diff --git a/lung_cancer_screening/core/jinja2/forms/imperial-height-input.jinja b/lung_cancer_screening/core/jinja2/forms/imperial-height-input.jinja
new file mode 100644
index 00000000..76b983dd
--- /dev/null
+++ b/lung_cancer_screening/core/jinja2/forms/imperial-height-input.jinja
@@ -0,0 +1,52 @@
+{% from 'nhsuk/components/input/macro.jinja' import input %}
+{% from 'nhsuk/components/error-message/macro.jinja' import errorMessage %}
+
+{% if field.errors | length > 0 %}
+ {% set error_message = field.errors | first %}
+{% endif %}
+{% set unbound_field = field.field %}
+{% set hint = unbound_field.hint %}
+{% set form_group_error_classes = ' nhsuk-form-group--error' if error_message else '' %}
+{% set field_error_classes = ' nhsuk-input--error' if error_message else '' %}
+
+
diff --git a/lung_cancer_screening/core/jinja2/forms/input.jinja b/lung_cancer_screening/core/jinja2/forms/input.jinja
new file mode 100644
index 00000000..b1f8e9fc
--- /dev/null
+++ b/lung_cancer_screening/core/jinja2/forms/input.jinja
@@ -0,0 +1,34 @@
+{% from "nhsuk/components/input/macro.jinja" import input %}
+{% set unbound_field = field.field %}
+{% set widget = unbound_field.widget %}
+{% if unbound_field.visually_hidden_label_suffix %}
+ {% set label_html %}
+ {{ field.label }}: {{ unbound_field.visually_hidden_label_suffix }}
+ {% endset %}
+{% endif %}
+{% set input_params = {
+ "label": {
+ "text": field.label,
+ "html": label_html if label_html,
+ "classes": unbound_field.label_classes if unbound_field.label_classes
+ },
+ "hint": {
+ "text": unbound_field.hint
+ } if unbound_field.hint,
+ "id": field.auto_id,
+ "name": field.html_name,
+ "value": field.value() or "",
+ "classes": unbound_field.classes if unbound_field.classes,
+ "attributes": widget.attrs,
+ "type": widget.input_type
+ } %}
+ {% if field.errors %}
+ {% set error_params = {
+ "errorMessage": {
+ "text": field.errors | first
+ }
+ } %}
+ {% set input_params = dict(input_params, **error_params) %}
+ {% endif %}
+
+{{ input(input_params) }}
diff --git a/lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py b/lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py
index a50a633e..10322a40 100644
--- a/lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py
+++ b/lung_cancer_screening/core/tests/acceptance/helpers/user_interaction_helpers.py
@@ -5,7 +5,7 @@ def fill_in_and_submit_participant_id(page, participant_id):
page.click('text=Start now')
-def fill_in_and_submit_smoking_elligibility(page, smoking_status):
+def fill_in_and_submit_smoking_eligibility(page, smoking_status):
expect(page.locator("legend")).to_have_text(
"Have you ever smoked?")
@@ -22,3 +22,18 @@ def fill_in_and_submit_date_of_birth(page, age):
page.get_by_label("Year").fill(str(age.year))
page.click("text=Continue")
+
+def fill_in_and_submit_height_metric(page, height):
+ expect(page.locator("h1")).to_have_text("What is your height?")
+
+ page.get_by_label("Centimetre").fill(str(height))
+
+ page.click("text=Continue")
+
+def fill_in_and_submit_height_imperial(page, feet, inches):
+ expect(page.locator("h1")).to_have_text("What is your height?")
+
+ page.get_by_label("Feet").fill(str(feet))
+ page.get_by_label("Inches").fill(str(inches))
+
+ page.click("text=Continue")
diff --git a/lung_cancer_screening/core/tests/acceptance/test_cannot_change_answers_after_submission.py b/lung_cancer_screening/core/tests/acceptance/test_cannot_change_answers_after_submission.py
index e1db0355..48e605c2 100644
--- a/lung_cancer_screening/core/tests/acceptance/test_cannot_change_answers_after_submission.py
+++ b/lung_cancer_screening/core/tests/acceptance/test_cannot_change_answers_after_submission.py
@@ -5,8 +5,9 @@
from dateutil.relativedelta import relativedelta
from .helpers.user_interaction_helpers import (
+ fill_in_and_submit_height_metric,
fill_in_and_submit_participant_id,
- fill_in_and_submit_smoking_elligibility,
+ fill_in_and_submit_smoking_eligibility,
fill_in_and_submit_date_of_birth
)
@@ -35,8 +36,9 @@ def test_cannot_change_responses_once_checked_and_submitted(self):
page.goto(f"{self.live_server_url}/start")
fill_in_and_submit_participant_id(page, participant_id)
- fill_in_and_submit_smoking_elligibility(page, smoking_status)
+ fill_in_and_submit_smoking_eligibility(page, smoking_status)
fill_in_and_submit_date_of_birth(page, age)
+ fill_in_and_submit_height_metric(page, "170")
page.click("text=Submit")
diff --git a/lung_cancer_screening/core/tests/acceptance/test_participant_not_smoker.py b/lung_cancer_screening/core/tests/acceptance/test_participant_not_smoker.py
index 8b733c2b..a352d49e 100644
--- a/lung_cancer_screening/core/tests/acceptance/test_participant_not_smoker.py
+++ b/lung_cancer_screening/core/tests/acceptance/test_participant_not_smoker.py
@@ -5,7 +5,7 @@
from .helpers.user_interaction_helpers import (
fill_in_and_submit_participant_id,
- fill_in_and_submit_smoking_elligibility
+ fill_in_and_submit_smoking_eligibility
)
@@ -31,7 +31,7 @@ def test_participant_not_smoker(self):
page.goto(f"{self.live_server_url}/start")
fill_in_and_submit_participant_id(page, participant_id)
- fill_in_and_submit_smoking_elligibility(page, 'No, I have never smoked')
+ fill_in_and_submit_smoking_eligibility(page, 'No, I have never smoked')
expect(page).to_have_url(f"{self.live_server_url}/non-smoker-exit")
diff --git a/lung_cancer_screening/core/tests/acceptance/test_participant_out_of_age_range.py b/lung_cancer_screening/core/tests/acceptance/test_participant_out_of_age_range.py
index 2c9b2014..e146d60f 100644
--- a/lung_cancer_screening/core/tests/acceptance/test_participant_out_of_age_range.py
+++ b/lung_cancer_screening/core/tests/acceptance/test_participant_out_of_age_range.py
@@ -6,7 +6,7 @@
from .helpers.user_interaction_helpers import (
fill_in_and_submit_participant_id,
- fill_in_and_submit_smoking_elligibility,
+ fill_in_and_submit_smoking_eligibility,
fill_in_and_submit_date_of_birth
)
@@ -33,7 +33,7 @@ def test_participant_out_of_age_range(self):
page.goto(f"{self.live_server_url}/start")
fill_in_and_submit_participant_id(page, participant_id)
- fill_in_and_submit_smoking_elligibility(page, 'Yes, I used to smoke regularly')
+ fill_in_and_submit_smoking_eligibility(page, 'Yes, I used to smoke regularly')
age = datetime.now() - relativedelta(years=20)
fill_in_and_submit_date_of_birth(page, age)
diff --git a/lung_cancer_screening/core/tests/acceptance/test_questionnaire.py b/lung_cancer_screening/core/tests/acceptance/test_questionnaire.py
index 5bb9f140..88a7c426 100644
--- a/lung_cancer_screening/core/tests/acceptance/test_questionnaire.py
+++ b/lung_cancer_screening/core/tests/acceptance/test_questionnaire.py
@@ -5,8 +5,10 @@
from dateutil.relativedelta import relativedelta
from .helpers.user_interaction_helpers import (
+ fill_in_and_submit_height_imperial,
+ fill_in_and_submit_height_metric,
fill_in_and_submit_participant_id,
- fill_in_and_submit_smoking_elligibility,
+ fill_in_and_submit_smoking_eligibility,
fill_in_and_submit_date_of_birth
)
@@ -27,10 +29,13 @@ def tearDownClass(cls):
cls.browser.close()
cls.playwright.stop()
- def test_full_questionaire_user_journey(self):
+ def test_full_questionnaire_user_journey(self):
participant_id = '123'
smoking_status = 'Yes, I used to smoke regularly'
age = datetime.now() - relativedelta(years=55)
+ height = "170"
+ feet = 5
+ inches = 7
page = self.browser.new_page()
page.goto(f"{self.live_server_url}/start")
@@ -40,19 +45,35 @@ def test_full_questionaire_user_journey(self):
expect(page).to_have_url(
f"{self.live_server_url}/have-you-ever-smoked")
- fill_in_and_submit_smoking_elligibility(page, smoking_status)
+ fill_in_and_submit_smoking_eligibility(page, smoking_status)
expect(page).to_have_url(f"{self.live_server_url}/date-of-birth")
expect_back_link_to_have_url(page, "/have-you-ever-smoked")
fill_in_and_submit_date_of_birth(page, age)
+ expect(page).to_have_url(f"{self.live_server_url}/height")
+
+ fill_in_and_submit_height_metric(page, height)
+
expect(page).to_have_url(f"{self.live_server_url}/responses")
- expect(page.locator(".responses")).to_contain_text(
- age.strftime("Have you ever smoked? Yes, I used to smoke regularly"))
- expect(page.locator(".responses")).to_contain_text(age.strftime("What is your date of birth? %Y-%m-%d"))
+ page.click("text=Back")
+
+ expect(page).to_have_url(f"{self.live_server_url}/height")
+
+ page.click("text=Switch to imperial")
+
+ fill_in_and_submit_height_imperial(page, feet, inches)
+
+ responses = page.locator(".responses")
+ expect(responses).to_contain_text("Have you ever smoked? Yes, I used to smoke regularly")
+ expect(responses).to_contain_text(
+ age.strftime("What is your date of birth? %Y-%m-%d"))
+ expect(responses).to_contain_text(f"What is your height? {feet} feet {inches} inches")
page.click("text=Submit")
+
+
expect(page).to_have_url(f"{self.live_server_url}/your-results")
diff --git a/lung_cancer_screening/core/tests/acceptance/test_questionnaire_validation_errors.py b/lung_cancer_screening/core/tests/acceptance/test_questionnaire_validation_errors.py
index 51917285..1139578b 100644
--- a/lung_cancer_screening/core/tests/acceptance/test_questionnaire_validation_errors.py
+++ b/lung_cancer_screening/core/tests/acceptance/test_questionnaire_validation_errors.py
@@ -37,3 +37,49 @@ def test_date_of_birth_validation_errors(self):
expect(page.locator(".nhsuk-error-message")).to_contain_text(
"Enter your date of birth."
)
+
+ def test_height_validation_errors(self):
+ participant_id = '123'
+
+ page = self.browser.new_page()
+ page.goto(f"{self.live_server_url}/start")
+ fill_in_and_submit_participant_id(page, participant_id)
+ page.goto(f"{self.live_server_url}/height")
+
+ page.click("text=Continue")
+ expect(page.locator(".nhsuk-error-message")).to_contain_text(
+ "Enter your height."
+ )
+
+ page.get_by_label("Centimetre").fill('139.6')
+ page.click('text=Continue')
+ expect(page.locator(".nhsuk-error-message")).to_contain_text(
+ "Height must be between 139.7cm and 243.8 cm"
+ )
+
+ page.get_by_label("Centimetre").fill('243.9')
+ page.click('text=Continue')
+ expect(page.locator(".nhsuk-error-message")).to_contain_text(
+ "Height must be between 139.7cm and 243.8 cm"
+ )
+
+ page.click("text=Switch to imperial")
+
+ page.click("text=Continue")
+ expect(page.locator(".nhsuk-error-message")).to_contain_text(
+ "Enter your height."
+ )
+
+ page.get_by_label("Feet").fill('5.2')
+ page.get_by_label("Inches").fill('2')
+ page.click('text=Continue')
+ expect(page.locator(".nhsuk-error-message")).to_contain_text(
+ "Feet must be in whole numbers"
+ )
+
+ page.get_by_label("Feet").fill('5')
+ page.get_by_label("Inches").fill('2.2')
+ page.click('text=Continue')
+ expect(page.locator(".nhsuk-error-message")).to_contain_text(
+ "Inches must be in whole numbers"
+ )
diff --git a/lung_cancer_screening/core/tests/unit/__init__.py b/lung_cancer_screening/core/tests/unit/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/core/tests/unit/test_form_fields.py b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
similarity index 100%
rename from lung_cancer_screening/core/tests/unit/test_form_fields.py
rename to lung_cancer_screening/core/tests/unit/not_test_form_fields.py
diff --git a/lung_cancer_screening/questions/forms/imperial_height_form.py b/lung_cancer_screening/questions/forms/imperial_height_form.py
new file mode 100644
index 00000000..d75cd293
--- /dev/null
+++ b/lung_cancer_screening/questions/forms/imperial_height_form.py
@@ -0,0 +1,28 @@
+from django import forms
+
+from lung_cancer_screening.core.form_fields import ImperialHeightField
+from ..models.response_set import ResponseSet
+
+class ImperialHeightForm(forms.ModelForm):
+
+ def __init__(self, *args, **kwargs):
+ self.participant = kwargs.pop('participant')
+ super().__init__(*args, **kwargs)
+ self.instance.participant = self.participant
+
+ self.fields["height_imperial"] = ImperialHeightField(
+ label="Height",
+ required=True,
+ require_all_fields=False,
+ error_messages={
+ 'required': 'Enter your height.',
+ 'incomplete': 'Enter your height.'
+ }
+ )
+
+ def clean_height(self):
+ return None
+
+ class Meta:
+ model = ResponseSet
+ fields = ['height_imperial', 'height']
diff --git a/lung_cancer_screening/questions/forms/metric_height_form.py b/lung_cancer_screening/questions/forms/metric_height_form.py
new file mode 100644
index 00000000..0ebecd9e
--- /dev/null
+++ b/lung_cancer_screening/questions/forms/metric_height_form.py
@@ -0,0 +1,29 @@
+from django import forms
+
+from lung_cancer_screening.core.form_fields import DecimalField
+from ..models.response_set import ResponseSet
+
+class MetricHeightForm(forms.ModelForm):
+
+ def __init__(self, *args, **kwargs):
+ self.participant = kwargs.pop('participant')
+ super().__init__(*args, **kwargs)
+ self.instance.participant = self.participant
+
+ self.fields["height"] = DecimalField(
+ label="Centimetres",
+ classes="nhsuk-input--width-4",
+ error_messages={
+ 'required': 'Enter your height.',
+ }
+ )
+
+ def clean_height(self):
+ return self.cleaned_data['height'] * 10
+
+ def clean_height_imperial(self):
+ return None
+
+ class Meta:
+ model = ResponseSet
+ fields = ['height', 'height_imperial']
diff --git a/lung_cancer_screening/questions/jinja2/height.jinja b/lung_cancer_screening/questions/jinja2/height.jinja
new file mode 100644
index 00000000..2d19600b
--- /dev/null
+++ b/lung_cancer_screening/questions/jinja2/height.jinja
@@ -0,0 +1,53 @@
+{% extends 'layout.jinja' %}
+{% from 'nhsuk/components/button/macro.jinja' import button %}
+{% from 'nhsuk/components/back-link/macro.jinja' import backLink %}
+{% from 'nhsuk/components/fieldset/macro.jinja' import fieldset %}
+
+{% block beforeContent %}
+ {{
+ backLink({
+ "href": url("questions:date_of_birth"),
+ "text": "Back"
+ })
+ }}
+{% endblock beforeContent %}
+
+{% block page_content %}
+
+
+ {% if unit %}
+ {% set action_url = request.path + '?unit=' + unit %}
+ {% else %}
+ {% set action_url = request.path %}
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/lung_cancer_screening/questions/jinja2/responses.jinja b/lung_cancer_screening/questions/jinja2/responses.jinja
index abaf1a09..043842be 100644
--- a/lung_cancer_screening/questions/jinja2/responses.jinja
+++ b/lung_cancer_screening/questions/jinja2/responses.jinja
@@ -1,13 +1,24 @@
{% extends 'layout.jinja' %}
{% from 'nhsuk/components/button/macro.jinja' import button %}
+{% from 'nhsuk/components/back-link/macro.jinja' import backLink %}
-{% block content %}
+{% block beforeContent %}
+ {{
+ backLink({
+ "href": url("questions:height"),
+ "text": "Back"
+ })
+ }}
+{% endblock beforeContent %}
+
+{% block page_content %}
Responses
- Have you ever smoked? {{ response_set.get_have_you_ever_smoked_display() }}
- What is your date of birth? {{ response_set.date_of_birth }}
+ - What is your height? {{ response_set.formatted_height }}