diff --git a/lung_cancer_screening/assets/sass/components/_button.scss b/lung_cancer_screening/assets/sass/components/_button.scss
new file mode 100644
index 00000000..24e1c4e6
--- /dev/null
+++ b/lung_cancer_screening/assets/sass/components/_button.scss
@@ -0,0 +1,21 @@
+.app-button--link {
+ @include nhsuk-link-style-default;
+
+ & {
+ display: inline;
+ align-items: normal;
+ text-align: left;
+ font-size: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+ }
+}
+
+.nhsuk-form-group .app-button--link {
+ display: block;
+}
diff --git a/lung_cancer_screening/assets/sass/main.scss b/lung_cancer_screening/assets/sass/main.scss
index a4da8895..f1b78508 100644
--- a/lung_cancer_screening/assets/sass/main.scss
+++ b/lung_cancer_screening/assets/sass/main.scss
@@ -1,2 +1,5 @@
// Import NHS.UK frontend library
@import "nhsuk-frontend/packages/nhsuk";
+
+// Components that are not in the NHS.UK frontend library
+// @import "components/button";
diff --git a/lung_cancer_screening/core/jinja2/home/index.jinja b/lung_cancer_screening/core/jinja2/home/index.jinja
deleted file mode 100644
index 7742abfd..00000000
--- a/lung_cancer_screening/core/jinja2/home/index.jinja
+++ /dev/null
@@ -1,5 +0,0 @@
-{% extends 'layout.jinja' %}
-
-{% block content %}
-
hello world
-{% endblock %}
diff --git a/lung_cancer_screening/core/tests/acceptance/test_homepage.py b/lung_cancer_screening/core/tests/acceptance/test_homepage.py
deleted file mode 100644
index 7356284f..00000000
--- a/lung_cancer_screening/core/tests/acceptance/test_homepage.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import os
-from django.contrib.staticfiles.testing import StaticLiveServerTestCase
-from playwright.sync_api import sync_playwright
-
-
-class TestQuestionnaire(StaticLiveServerTestCase):
-
- @classmethod
- def setUpClass(cls):
- os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
- super().setUpClass()
- cls.playwright = sync_playwright().start()
- cls.browser = cls.playwright.chromium.launch()
-
- @classmethod
- def tearDownClass(cls):
- super().tearDownClass()
- cls.browser.close()
- cls.playwright.stop()
-
- def test_full_questionaire_user_journey(self):
- page = self.browser.new_page()
- page.goto(f"{self.live_server_url}/")
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
new file mode 100644
index 00000000..c9aa9178
--- /dev/null
+++ b/lung_cancer_screening/core/tests/acceptance/test_participant_not_smoker.py
@@ -0,0 +1,43 @@
+import os
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from playwright.sync_api import sync_playwright, expect
+
+
+class TestParticipantNotSmoker(StaticLiveServerTestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
+ super().setUpClass()
+ cls.playwright = sync_playwright().start()
+ cls.browser = cls.playwright.chromium.launch()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ cls.browser.close()
+ cls.playwright.stop()
+
+ def test_participant_not_smoker(self):
+ participant_id = '123'
+
+ page = self.browser.new_page()
+ page.goto(f"{self.live_server_url}/start")
+
+ page.fill("input[name='participant_id']", participant_id)
+
+ page.click('text=Start now')
+
+ expect(page).to_have_url(f"{self.live_server_url}/have-you-ever-smoked")
+
+ expect(page.locator("legend")).to_have_text(
+ "Have you ever smoked?")
+
+ page.get_by_label('No').check()
+
+ page.click("text=Continue")
+
+ expect(page).to_have_url(f"{self.live_server_url}/non-smoker-exit")
+
+ expect(page.locator(".title")).to_have_text(
+ "You do not need an NHS lung health check")
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
new file mode 100644
index 00000000..d20252dc
--- /dev/null
+++ b/lung_cancer_screening/core/tests/acceptance/test_participant_out_of_age_range.py
@@ -0,0 +1,59 @@
+import os
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from playwright.sync_api import sync_playwright, expect
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+
+class TestParticipantOutOfAgeRange(StaticLiveServerTestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
+ super().setUpClass()
+ cls.playwright = sync_playwright().start()
+ cls.browser = cls.playwright.chromium.launch()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ cls.browser.close()
+ cls.playwright.stop()
+
+ def test_participant_out_of_age_range(self):
+ participant_id = '123'
+
+ page = self.browser.new_page()
+ page.goto(f"{self.live_server_url}/start")
+
+ page.fill("input[name='participant_id']", participant_id)
+
+ page.click('text=Start now')
+
+ expect(page).to_have_url(
+ f"{self.live_server_url}/have-you-ever-smoked")
+
+ expect(page.locator("legend")).to_have_text(
+ "Have you ever smoked?")
+
+ page.get_by_label('Yes').check()
+
+ page.click("text=Continue")
+
+ expect(page).to_have_url(f"{self.live_server_url}/date-of-birth")
+
+ expect(page.locator("legend")).to_have_text(
+ "What is your date of birth?")
+
+ age = datetime.now() - relativedelta(years=20)
+
+ page.fill("input[name='day']", str(age.day))
+ page.fill("input[name='month']", str(age.month))
+ page.fill("input[name='year']", str(age.year))
+
+ page.click("text=Continue")
+
+ expect(page).to_have_url(f"{self.live_server_url}/age-range-exit")
+
+ expect(page.locator(".title")).to_have_text(
+ "You do not need an NHS lung health check")
diff --git a/lung_cancer_screening/core/tests/acceptance/test_questionnaire.py b/lung_cancer_screening/core/tests/acceptance/test_questionnaire.py
new file mode 100644
index 00000000..01d58f36
--- /dev/null
+++ b/lung_cancer_screening/core/tests/acceptance/test_questionnaire.py
@@ -0,0 +1,59 @@
+import os
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from playwright.sync_api import sync_playwright, expect
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+class TestQuestionnaire(StaticLiveServerTestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
+ super().setUpClass()
+ cls.playwright = sync_playwright().start()
+ cls.browser = cls.playwright.chromium.launch()
+
+ @classmethod
+ def tearDownClass(cls):
+ super().tearDownClass()
+ cls.browser.close()
+ cls.playwright.stop()
+
+ def test_full_questionaire_user_journey(self):
+ participant_id = '123'
+
+ page = self.browser.new_page()
+ page.goto(f"{self.live_server_url}/start")
+
+ page.fill("input[name='participant_id']", participant_id)
+
+ page.click('text=Start now')
+
+ expect(page).to_have_url(
+ f"{self.live_server_url}/have-you-ever-smoked")
+
+ expect(page.locator("legend")).to_have_text(
+ "Have you ever smoked?")
+
+ page.get_by_label('Yes').check()
+
+ page.click("text=Continue")
+
+ expect(page).to_have_url(f"{self.live_server_url}/date-of-birth")
+
+ expect(page.locator("legend")).to_have_text(
+ "What is your date of birth?")
+
+ age = datetime.now() - relativedelta(years=55)
+
+ page.fill("input[name='day']", str(age.day))
+ page.fill("input[name='month']", str(age.month))
+ page.fill("input[name='year']", str(age.year))
+
+ page.click("text=Continue")
+
+ expect(page).to_have_url(f"{self.live_server_url}/responses")
+
+ expect(page.locator(".responses")).to_contain_text(
+ age.strftime("Have you ever smoked? True"))
+ expect(page.locator(".responses")).to_contain_text(age.strftime("What is your date of birth? %Y-%m-%d"))
diff --git a/lung_cancer_screening/questions/__init__.py b/lung_cancer_screening/questions/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/questions/jinja2/age_range_exit.jinja b/lung_cancer_screening/questions/jinja2/age_range_exit.jinja
new file mode 100644
index 00000000..8e7c2094
--- /dev/null
+++ b/lung_cancer_screening/questions/jinja2/age_range_exit.jinja
@@ -0,0 +1,13 @@
+{% extends 'layout.jinja' %}
+{% from 'button/macro.jinja' import button %}
+{% from 'input/macro.jinja' import input %}
+
+{% block content %}
+
+
+
You do not need an NHS lung health check
+
+
The NHS lung health check is for people between the ages of 55 and 74.
+
+
+{% endblock %}
diff --git a/lung_cancer_screening/questions/jinja2/date_of_birth.jinja b/lung_cancer_screening/questions/jinja2/date_of_birth.jinja
new file mode 100644
index 00000000..4263d1dd
--- /dev/null
+++ b/lung_cancer_screening/questions/jinja2/date_of_birth.jinja
@@ -0,0 +1,47 @@
+{% extends 'layout.jinja' %}
+
+{% from 'date-input/macro.jinja' import dateInput %}
+{% from 'button/macro.jinja' import button %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/lung_cancer_screening/questions/jinja2/have_you_ever_smoked.jinja b/lung_cancer_screening/questions/jinja2/have_you_ever_smoked.jinja
new file mode 100644
index 00000000..9ed46241
--- /dev/null
+++ b/lung_cancer_screening/questions/jinja2/have_you_ever_smoked.jinja
@@ -0,0 +1,40 @@
+{% extends 'layout.jinja' %}
+{% from 'button/macro.jinja' import button %}
+{% from 'radios/macro.jinja' import radios %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/lung_cancer_screening/questions/jinja2/non_smoker_exit.jinja b/lung_cancer_screening/questions/jinja2/non_smoker_exit.jinja
new file mode 100644
index 00000000..4ff9defe
--- /dev/null
+++ b/lung_cancer_screening/questions/jinja2/non_smoker_exit.jinja
@@ -0,0 +1,13 @@
+{% extends 'layout.jinja' %}
+{% from 'button/macro.jinja' import button %}
+{% from 'input/macro.jinja' import input %}
+
+{% block content %}
+
+
+
You do not need an NHS lung health check
+
+
The NHS lung health check is for people who have smoked or are current smokers.
+
+
+{% endblock %}
diff --git a/lung_cancer_screening/questions/jinja2/responses.jinja b/lung_cancer_screening/questions/jinja2/responses.jinja
new file mode 100644
index 00000000..57878e6e
--- /dev/null
+++ b/lung_cancer_screening/questions/jinja2/responses.jinja
@@ -0,0 +1,15 @@
+{% extends 'layout.jinja' %}
+
+
+{% block content %}
+
+
+
Responses
+
+ {% for response in responses %}
+ - {{ response.question }} {{ response.value }}
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/lung_cancer_screening/questions/jinja2/start.jinja b/lung_cancer_screening/questions/jinja2/start.jinja
new file mode 100644
index 00000000..91ad6762
--- /dev/null
+++ b/lung_cancer_screening/questions/jinja2/start.jinja
@@ -0,0 +1,25 @@
+{% extends 'layout.jinja' %}
+{% from 'button/macro.jinja' import button %}
+{% from 'input/macro.jinja' import input %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/lung_cancer_screening/questions/migrations/0001_initial.py b/lung_cancer_screening/questions/migrations/0001_initial.py
new file mode 100644
index 00000000..2ca81bd6
--- /dev/null
+++ b/lung_cancer_screening/questions/migrations/0001_initial.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.4 on 2025-09-03 11:05
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Participant',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('unique_id', models.CharField(max_length=255, unique=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='QuestionnaireResponse',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('value', models.DateField()),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questions.participant')),
+ ],
+ ),
+ ]
diff --git a/lung_cancer_screening/questions/migrations/0002_booleanresponse.py b/lung_cancer_screening/questions/migrations/0002_booleanresponse.py
new file mode 100644
index 00000000..f856a39a
--- /dev/null
+++ b/lung_cancer_screening/questions/migrations/0002_booleanresponse.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.2.4 on 2025-09-03 16:03
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('questions', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BooleanResponse',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('value', models.BooleanField()),
+ ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='questions.participant')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/lung_cancer_screening/questions/migrations/0003_booleanresponse_question_and_more.py b/lung_cancer_screening/questions/migrations/0003_booleanresponse_question_and_more.py
new file mode 100644
index 00000000..c473eaba
--- /dev/null
+++ b/lung_cancer_screening/questions/migrations/0003_booleanresponse_question_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.4 on 2025-09-04 10:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('questions', '0002_booleanresponse'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='BooleanResponse',
+ name='question',
+ field=models.CharField(null='Have you ever smoked?', max_length=255),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='QuestionnaireResponse',
+ name='question',
+ field=models.CharField(default='What is your date of birth?', max_length=255),
+ preserve_default=False,
+ ),
+ ]
diff --git a/lung_cancer_screening/questions/migrations/0004_rename_questionnaireresponse_to_dateresponse.py b/lung_cancer_screening/questions/migrations/0004_rename_questionnaireresponse_to_dateresponse.py
new file mode 100644
index 00000000..73fc8174
--- /dev/null
+++ b/lung_cancer_screening/questions/migrations/0004_rename_questionnaireresponse_to_dateresponse.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.4 on 2025-09-04 10:10
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('questions', '0003_booleanresponse_question_and_more'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='QuestionnaireResponse',
+ new_name='DateResponse',
+ ),
+
+ ]
diff --git a/lung_cancer_screening/questions/migrations/__init__.py b/lung_cancer_screening/questions/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/questions/models/__init__.py b/lung_cancer_screening/questions/models/__init__.py
new file mode 100644
index 00000000..410a937b
--- /dev/null
+++ b/lung_cancer_screening/questions/models/__init__.py
@@ -0,0 +1,3 @@
+from .boolean_response import BooleanResponse # noqa: F401
+from .date_response import DateResponse # noqa: F401
+from .participant import Participant # noqa: F401
diff --git a/lung_cancer_screening/questions/models/base.py b/lung_cancer_screening/questions/models/base.py
new file mode 100644
index 00000000..1fe393e1
--- /dev/null
+++ b/lung_cancer_screening/questions/models/base.py
@@ -0,0 +1,13 @@
+from django.db import models
+
+
+class BaseModel(models.Model):
+ class Meta:
+ abstract = True
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def save(self, *args, **kwargs):
+ self.full_clean() # Validate before saving
+ super().save(*args, **kwargs)
diff --git a/lung_cancer_screening/questions/models/boolean_response.py b/lung_cancer_screening/questions/models/boolean_response.py
new file mode 100644
index 00000000..69727eb2
--- /dev/null
+++ b/lung_cancer_screening/questions/models/boolean_response.py
@@ -0,0 +1,9 @@
+from django.db import models
+from .base import BaseModel
+from .participant import Participant
+
+class BooleanResponse(BaseModel):
+ participant = models.ForeignKey(Participant, on_delete=models.CASCADE)
+
+ question = models.CharField(max_length=255)
+ value = models.BooleanField()
diff --git a/lung_cancer_screening/questions/models/date_response.py b/lung_cancer_screening/questions/models/date_response.py
new file mode 100644
index 00000000..090321f5
--- /dev/null
+++ b/lung_cancer_screening/questions/models/date_response.py
@@ -0,0 +1,9 @@
+from django.db import models
+from .base import BaseModel
+from .participant import Participant
+
+class DateResponse(BaseModel):
+ participant = models.ForeignKey(Participant, on_delete=models.CASCADE)
+
+ question = models.CharField(max_length=255)
+ value = models.DateField()
diff --git a/lung_cancer_screening/questions/models/participant.py b/lung_cancer_screening/questions/models/participant.py
new file mode 100644
index 00000000..0191b379
--- /dev/null
+++ b/lung_cancer_screening/questions/models/participant.py
@@ -0,0 +1,8 @@
+from django.db import models
+from .base import BaseModel
+
+class Participant(BaseModel):
+ unique_id = models.CharField(max_length=255, unique=True)
+
+ def responses(self):
+ return list(self.dateresponse_set.all()) + list(self.booleanresponse_set.all())
diff --git a/lung_cancer_screening/questions/tests/__init__.py b/lung_cancer_screening/questions/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/questions/tests/unit/__init__.py b/lung_cancer_screening/questions/tests/unit/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/questions/tests/unit/models/__init__.py b/lung_cancer_screening/questions/tests/unit/models/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/questions/tests/unit/models/test_boolean_response.py b/lung_cancer_screening/questions/tests/unit/models/test_boolean_response.py
new file mode 100644
index 00000000..ac92853c
--- /dev/null
+++ b/lung_cancer_screening/questions/tests/unit/models/test_boolean_response.py
@@ -0,0 +1,47 @@
+from django.test import TestCase
+from datetime import datetime
+from django.core.exceptions import ValidationError
+
+from lung_cancer_screening.questions.models.participant import Participant
+from lung_cancer_screening.questions.models.boolean_response import BooleanResponse
+
+class TestBooleanResponse(TestCase):
+ def setUp(self):
+ participant = Participant.objects.create(unique_id="12345")
+ self.boolean_response = BooleanResponse.objects.create(
+ value=True,
+ participant=participant,
+ question="Asking something generic?"
+ )
+
+ # def test_beloings_to_participant_as_a_string(self):
+ # self.assertIsInstance(
+ # self.boolean_response.participant_id,
+ # str
+ # )
+
+ def test_has_value_as_a_boolean(self):
+ self.assertIsInstance(
+ self.boolean_response.value,
+ bool
+ )
+
+ def test_has_created_at_as_a_datetime(self):
+ self.assertIsInstance(
+ self.boolean_response.created_at,
+ datetime
+ )
+
+ def test_has_updated_at_as_a_datetime(self):
+ self.assertIsInstance(
+ self.boolean_response.updated_at,
+ datetime
+ )
+
+ def test_raises_a_validation_error_if_the_value_is_null(self):
+ with self.assertRaises(ValidationError):
+ BooleanResponse.objects.create(value=None)
+
+ def test_raises_a_validation_error_if_the_question_is_null(self):
+ with self.assertRaises(ValidationError):
+ BooleanResponse.objects.create(question=None)
diff --git a/lung_cancer_screening/questions/tests/unit/models/test_date_response.py b/lung_cancer_screening/questions/tests/unit/models/test_date_response.py
new file mode 100644
index 00000000..c40f0567
--- /dev/null
+++ b/lung_cancer_screening/questions/tests/unit/models/test_date_response.py
@@ -0,0 +1,47 @@
+from django.test import TestCase
+from datetime import datetime, date
+from django.core.exceptions import ValidationError
+
+from lung_cancer_screening.questions.models.participant import Participant
+from lung_cancer_screening.questions.models.date_response import DateResponse
+
+class TestDateResponse(TestCase):
+ def setUp(self):
+ participant = Participant.objects.create(unique_id="12345")
+ self.date_response = DateResponse.objects.create(
+ value=date(2000, 1, 1),
+ participant=participant,
+ question="Asking something generic?"
+ )
+
+ # def test_beloings_to_participant_as_a_string(self):
+ # self.assertIsInstance(
+ # self.date_response.participant_id,
+ # str
+ # )
+
+ def test_has_value_as_a_date(self):
+ self.assertIsInstance(
+ self.date_response.value,
+ date
+ )
+
+ def test_has_created_at_as_a_datetime(self):
+ self.assertIsInstance(
+ self.date_response.created_at,
+ datetime
+ )
+
+ def test_has_updated_at_as_a_datetime(self):
+ self.assertIsInstance(
+ self.date_response.updated_at,
+ datetime
+ )
+
+ def test_raises_a_validation_error_if_the_value_is_null(self):
+ with self.assertRaises(ValidationError):
+ DateResponse.objects.create(value=None)
+
+ def test_raises_a_validation_error_if_the_question_is_null(self):
+ with self.assertRaises(ValidationError):
+ DateResponse.objects.create(question=None)
diff --git a/lung_cancer_screening/questions/tests/unit/models/test_participant.py b/lung_cancer_screening/questions/tests/unit/models/test_participant.py
new file mode 100644
index 00000000..5f8380b3
--- /dev/null
+++ b/lung_cancer_screening/questions/tests/unit/models/test_participant.py
@@ -0,0 +1,70 @@
+from django.test import TestCase
+from datetime import datetime, date
+from django.core.exceptions import ValidationError
+
+
+from lung_cancer_screening.questions.models.participant import Participant
+from lung_cancer_screening.questions.models.date_response import DateResponse
+from lung_cancer_screening.questions.models.boolean_response import BooleanResponse
+
+class TestParticipant(TestCase):
+ def setUp(self):
+ self.participant = Participant.objects.create(unique_id="12345")
+
+ def test_has_unique_id_as_a_string(self):
+ self.assertIsInstance(
+ self.participant.unique_id,
+ str
+ )
+
+ def test_has_created_at_as_a_datetime(self):
+ self.assertIsInstance(
+ self.participant.created_at,
+ datetime
+ )
+
+ def test_has_updated_at_as_a_datetime(self):
+ self.assertIsInstance(
+ self.participant.updated_at,
+ datetime
+ )
+
+ def test_has_many_boolean_responses(self):
+ boolean_response = BooleanResponse.objects.create(
+ value=True,
+ participant=self.participant,
+ question="Asking something else generic?"
+ )
+ self.assertIn(boolean_response, list(self.participant.booleanresponse_set.all()))
+
+ def test_has_many_date_responses(self):
+ date_response = DateResponse.objects.create(
+ value=date(2000, 9, 8),
+ participant=self.participant,
+ question="Asking something generic?"
+ )
+ self.assertIn(date_response, list(self.participant.dateresponse_set.all()))
+
+ def test_has_many_responses(self):
+ boolean_response = BooleanResponse.objects.create(
+ value=True,
+ participant=self.participant,
+ question="Asking something else generic?"
+ )
+ date_response = DateResponse.objects.create(
+ value=date(2000, 9, 8),
+ participant=self.participant,
+ question="Asking something generic?"
+ )
+
+ responses = self.participant.responses()
+ self.assertIn(boolean_response, responses)
+ self.assertIn(date_response, responses)
+
+ def test_raises_a_validation_error_if_the_unique_id_is_null(self):
+ with self.assertRaises(ValidationError):
+ Participant.objects.create(unique_id=None)
+
+ def test_raises_a_validation_error_if_the_unique_id_is_empty(self):
+ with self.assertRaises(ValidationError):
+ Participant.objects.create(unique_id="")
diff --git a/lung_cancer_screening/questions/tests/unit/views/__init__.py b/lung_cancer_screening/questions/tests/unit/views/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/questions/tests/unit/views/test_date_of_birth.py b/lung_cancer_screening/questions/tests/unit/views/test_date_of_birth.py
new file mode 100644
index 00000000..e39f610d
--- /dev/null
+++ b/lung_cancer_screening/questions/tests/unit/views/test_date_of_birth.py
@@ -0,0 +1,115 @@
+from django.test import TestCase
+from django.urls import reverse
+from datetime import date
+from dateutil.relativedelta import relativedelta
+
+from lung_cancer_screening.questions.models.participant import Participant
+from lung_cancer_screening.questions.models.date_response import DateResponse
+
+class TestPostDateOfBirth(TestCase):
+ def setUp(self):
+ self.participant = Participant.objects.create(unique_id="12345")
+ self.valid_age = date.today() - relativedelta(years=55)
+ self.valid_params = {
+ "day": self.valid_age.day,
+ "month": self.valid_age.month,
+ "year": self.valid_age.year
+ }
+
+ self.invalid_age = date.today() - relativedelta(years=20)
+ self.invalid_params = {
+ "day": self.invalid_age.day,
+ "month": self.invalid_age.month,
+ "year": self.invalid_age.year
+ }
+
+ session = self.client.session
+ session['participant_id'] = self.participant.unique_id
+ session.save()
+
+ def test_get_redirects_if_the_particpant_does_not_exist(self):
+ session = self.client.session
+ session['participant_id'] = "somebody none existant participant"
+ session.save()
+
+ response = self.client.get(
+ reverse("questions:date_of_birth")
+ )
+
+ self.assertRedirects(response, reverse("questions:start"))
+
+ def test_get_responds_successfully(self):
+ response = self.client.get(reverse("questions:date_of_birth"))
+
+ self.assertEqual(response.status_code, 200)
+
+ def test_get_contains_the_participant_id_in_the_form(self):
+ response = self.client.get(reverse("questions:date_of_birth"))
+
+ self.assertContains(
+ response,
+ f""
+ )
+
+ def test_post_redirects_if_the_particpant_does_not_exist(self):
+ session = self.client.session
+ session['participant_id'] = "somebody none existant participant"
+ session.save()
+
+ response = self.client.post(
+ reverse("questions:date_of_birth"),
+ self.valid_params
+ )
+
+ self.assertRedirects(response, reverse("questions:start"))
+
+ def test_post_stores_a_valid_date_response_for_the_participant(self):
+ self.client.post(
+ reverse("questions:date_of_birth"),
+ self.valid_params
+ )
+
+ date_response = DateResponse.objects.first()
+ self.assertEqual(date_response.value, self.valid_age)
+ self.assertEqual(date_response.participant, self.participant)
+ self.assertEqual(date_response.question, "What is your date of birth?")
+
+ def test_post_sets_the_participant_id_in_session(self):
+ self.client.post(
+ reverse("questions:date_of_birth"),
+ self.valid_params
+ )
+
+ self.assertEqual(self.client.session["participant_id"], "12345")
+
+ def test_post_redirects_to_responses_path(self):
+ response = self.client.post(
+ reverse("questions:date_of_birth"),
+ self.valid_params
+ )
+
+ self.assertRedirects(response, reverse("questions:responses"))
+
+ def test_post_responds_with_422_if_the_date_response_fails_to_create(self):
+ response = self.client.post(
+ reverse("questions:date_of_birth"),
+ {"day": "80000", "month": "90000", "year": "20000000"}
+ )
+
+ self.assertEqual(response.status_code, 422)
+
+ def test_post_does_not_create_a_date_response_if_the_user_is_not_in_the_correct_age_range(self):
+ self.client.post(
+ reverse("questions:date_of_birth"),
+ self.invalid_params
+ )
+
+ self.assertEqual(DateResponse.objects.count(), 0)
+
+ def test_post_redirects_if_the_user_is_not_in_the_correct_age_range(self):
+ response = self.client.post(
+ reverse("questions:date_of_birth"),
+ self.invalid_params
+ )
+
+ self.assertRedirects(response, reverse("questions:age_range_exit"))
diff --git a/lung_cancer_screening/questions/tests/unit/views/test_have_you_ever_smoked.py b/lung_cancer_screening/questions/tests/unit/views/test_have_you_ever_smoked.py
new file mode 100644
index 00000000..42b697e3
--- /dev/null
+++ b/lung_cancer_screening/questions/tests/unit/views/test_have_you_ever_smoked.py
@@ -0,0 +1,101 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from lung_cancer_screening.questions.models.participant import Participant
+from lung_cancer_screening.questions.models.boolean_response import BooleanResponse
+
+class TestHaveYouEverSmoked(TestCase):
+ def setUp(self):
+ self.participant = Participant.objects.create(unique_id="12345")
+ self.valid_params = { "value": 1 }
+
+ session = self.client.session
+ session['participant_id'] = self.participant.unique_id
+ session.save()
+
+ def test_get_redirects_if_the_particpant_does_not_exist(self):
+ session = self.client.session
+ session['participant_id'] = "somebody none existant participant"
+ session.save()
+
+ response = self.client.get(
+ reverse("questions:have_you_ever_smoked")
+ )
+
+ self.assertRedirects(response, reverse("questions:start"))
+
+ def test_get_responds_successfully(self):
+ response = self.client.get(reverse("questions:have_you_ever_smoked"))
+
+ self.assertEqual(response.status_code, 200)
+
+ def test_get_contains_the_participant_id_in_the_form(self):
+ response = self.client.get(reverse("questions:have_you_ever_smoked"))
+
+ self.assertContains(
+ response,
+ f""
+ )
+
+ def test_post_redirects_if_the_particpant_does_not_exist(self):
+ session = self.client.session
+ session['participant_id'] = "somebody none existant participant"
+ session.save()
+
+ response = self.client.post(
+ reverse("questions:have_you_ever_smoked"),
+ self.valid_params
+ )
+
+ self.assertRedirects(response, reverse("questions:start"))
+
+ def test_post_stores_a_valid_date_response_for_the_participant(self):
+ self.client.post(
+ reverse("questions:have_you_ever_smoked"),
+ self.valid_params
+ )
+
+ date_response = BooleanResponse.objects.first()
+ self.assertEqual(date_response.value, self.valid_params["value"])
+ self.assertEqual(date_response.participant, self.participant)
+ self.assertEqual(date_response.question, "Have you ever smoked?")
+
+ def test_post_sets_the_participant_id_in_session(self):
+ self.client.post(
+ reverse("questions:have_you_ever_smoked"),
+ self.valid_params
+ )
+
+ self.assertEqual(self.client.session["participant_id"], "12345")
+
+ def test_post_redirects_to_the_date_of_birth_path(self):
+ response = self.client.post(
+ reverse("questions:have_you_ever_smoked"),
+ self.valid_params
+ )
+
+ self.assertRedirects(response, reverse("questions:date_of_birth"))
+
+ def test_post_responds_with_422_if_the_date_response_fails_to_create(self):
+ response = self.client.post(
+ reverse("questions:have_you_ever_smoked"),
+ {"value": "something not a boolean"}
+ )
+
+ self.assertEqual(response.status_code, 422)
+
+ def test_post_does_not_create_a_date_response_if_the_user_is_not_a_smoker(self):
+ self.client.post(
+ reverse("questions:have_you_ever_smoked"),
+ { "value": False }
+ )
+
+ self.assertEqual(BooleanResponse.objects.count(), 0)
+
+ def test_post_redirects_if_the_user_not_a_smoker(self):
+ response = self.client.post(
+ reverse("questions:have_you_ever_smoked"),
+ {"value": 0}
+ )
+
+ self.assertRedirects(response, reverse("questions:non_smoker_exit"))
diff --git a/lung_cancer_screening/questions/tests/unit/views/test_responses.py b/lung_cancer_screening/questions/tests/unit/views/test_responses.py
new file mode 100644
index 00000000..6c08618f
--- /dev/null
+++ b/lung_cancer_screening/questions/tests/unit/views/test_responses.py
@@ -0,0 +1,57 @@
+from django.test import TestCase
+from django.urls import reverse
+from datetime import date
+
+from lung_cancer_screening.questions.models.participant import Participant
+from lung_cancer_screening.questions.models.date_response import DateResponse
+from lung_cancer_screening.questions.models.boolean_response import BooleanResponse
+
+class TestResponses(TestCase):
+
+ def setUp(self):
+ self.participant = Participant.objects.create(unique_id='12345')
+
+ session = self.client.session
+ session['participant_id'] = self.participant.unique_id
+ session.save()
+
+ def test_redirects_if_the_participant_does_not_exist(self):
+ session = self.client.session
+ session['participant_id'] = "somebody none existant participant"
+ session.save()
+
+ response = self.client.get(reverse("questions:responses"))
+
+ self.assertRedirects(response, reverse("questions:start"))
+
+ def test_responds_successfully(self):
+ response = self.client.get(reverse("questions:responses"))
+
+ self.assertEqual(response.status_code, 200)
+
+ def test_contains_the_participants_responses(self):
+ date_response = DateResponse.objects.create(
+ value=date(2000, 9, 8),
+ participant=self.participant,
+ question="Asking something generic?"
+ )
+ boolean_response = BooleanResponse.objects.create(
+ value=True,
+ participant=self.participant,
+ question="Asking something else generic?"
+ )
+
+ response = self.client.get(reverse("questions:responses"))
+
+ self.assertContains(response, date_response.question)
+ self.assertContains(response, date_response.value)
+ self.assertContains(response, boolean_response.question)
+ self.assertContains(response, boolean_response.value)
+
+ def test_does_not_contain_responses_for_other_participants(self):
+ other_participant = Participant.objects.create(unique_id='67890')
+ other_date_response = DateResponse.objects.create(value=date(1990, 1, 1), participant=other_participant, question="Asking something generic?")
+
+ response = self.client.get(reverse("questions:responses"))
+
+ self.assertNotContains(response, other_date_response.value)
diff --git a/lung_cancer_screening/questions/tests/unit/views/test_start.py b/lung_cancer_screening/questions/tests/unit/views/test_start.py
new file mode 100644
index 00000000..d87bcd04
--- /dev/null
+++ b/lung_cancer_screening/questions/tests/unit/views/test_start.py
@@ -0,0 +1,43 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from lung_cancer_screening.questions.models.participant import Participant
+
+class TestStart(TestCase):
+
+ def test_get_responds_successfully(self):
+ response = self.client.get(reverse("questions:start"))
+
+ self.assertEqual(response.status_code, 200)
+
+ def test_post_creates_a_new_participant(self):
+ self.client.post(
+ reverse("questions:start"),
+ {"participant_id": "12345"}
+ )
+
+ self.assertEqual(Participant.objects.all().last().unique_id, "12345")
+
+ def test_post_sets_the_participant_id_in_session(self):
+ self.client.post(
+ reverse("questions:start"),
+ {"participant_id": "12345"}
+ )
+
+ self.assertEqual(self.client.session["participant_id"], "12345")
+
+ def test_post_redirects_to_the_date_of_birth_path(self):
+ response = self.client.post(
+ reverse("questions:start"),
+ {"participant_id": "12345"}
+ )
+
+ self.assertRedirects(response, reverse("questions:have_you_ever_smoked"))
+
+ def test_post_responds_with_422_if_the_participant_fails_to_create(self):
+ response = self.client.post(
+ reverse("questions:start"),
+ {"participant_id": ""}
+ )
+
+ self.assertEqual(response.status_code, 422)
diff --git a/lung_cancer_screening/questions/urls.py b/lung_cancer_screening/questions/urls.py
new file mode 100644
index 00000000..76ca284f
--- /dev/null
+++ b/lung_cancer_screening/questions/urls.py
@@ -0,0 +1,32 @@
+"""
+URL configuration for lung_cancer_screening project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/5.2/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.urls import path
+from .views.start import start
+from .views.have_you_ever_smoked import have_you_ever_smoked
+from .views.date_of_birth import date_of_birth
+from .views.responses import responses
+from .views.age_range_exit import age_range_exit
+from .views.non_smoker_exit import non_smoker_exit
+
+urlpatterns = [
+ path('start', start, name='start'),
+ path('have-you-ever-smoked', have_you_ever_smoked, name='have_you_ever_smoked'),
+ path('date-of-birth', date_of_birth, name='date_of_birth'),
+ path('responses', responses, name='responses'),
+ path('age-range-exit', age_range_exit, name='age_range_exit'),
+ path('non-smoker-exit', non_smoker_exit, name='non_smoker_exit'),
+]
diff --git a/lung_cancer_screening/questions/views/__init__.py b/lung_cancer_screening/questions/views/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/views.py b/lung_cancer_screening/questions/views/age_range_exit.py
similarity index 56%
rename from lung_cancer_screening/views.py
rename to lung_cancer_screening/questions/views/age_range_exit.py
index 040ace6d..b908c171 100644
--- a/lung_cancer_screening/views.py
+++ b/lung_cancer_screening/questions/views/age_range_exit.py
@@ -1,7 +1,7 @@
from django.shortcuts import render
-def home(request):
+def age_range_exit(request):
return render(
request,
- "home/index.jinja"
+ "age_range_exit.jinja"
)
diff --git a/lung_cancer_screening/questions/views/date_of_birth.py b/lung_cancer_screening/questions/views/date_of_birth.py
new file mode 100644
index 00000000..0e2fd77f
--- /dev/null
+++ b/lung_cancer_screening/questions/views/date_of_birth.py
@@ -0,0 +1,52 @@
+from django.shortcuts import render, redirect
+from django.urls import reverse
+from datetime import date
+from dateutil.relativedelta import relativedelta
+from django.core.exceptions import ValidationError
+
+from ..models.participant import Participant
+from ..models.date_response import DateResponse
+
+
+def date_of_birth(request):
+ try:
+ participant = Participant.objects.get(
+ unique_id=request.session['participant_id'])
+ except Participant.DoesNotExist:
+ return redirect(reverse("questions:start"))
+
+ if request.method == "POST":
+ try:
+ value = date(
+ int(request.POST['year']),
+ int(request.POST['month']),
+ int(request.POST['day'])
+ )
+
+ fifty_five_years_ago = date.today() - relativedelta(years=55)
+ seventy_five_years_ago = date.today() - relativedelta(years=75)
+
+ if value in (fifty_five_years_ago, seventy_five_years_ago):
+ DateResponse.objects.create(
+ participant=participant,
+ value=value,
+ question="What is your date of birth?"
+ )
+
+ return redirect(reverse("questions:responses"))
+ else:
+ return redirect(reverse("questions:age_range_exit"))
+
+ except (ValueError, ValidationError):
+ return render(
+ request,
+ "date_of_birth.jinja",
+ {"participant_id": participant.unique_id},
+ status=422
+ )
+
+ return render(
+ request,
+ "date_of_birth.jinja",
+ { "participant_id": participant.unique_id }
+ )
diff --git a/lung_cancer_screening/questions/views/have_you_ever_smoked.py b/lung_cancer_screening/questions/views/have_you_ever_smoked.py
new file mode 100644
index 00000000..3f5e44c8
--- /dev/null
+++ b/lung_cancer_screening/questions/views/have_you_ever_smoked.py
@@ -0,0 +1,43 @@
+from django.shortcuts import render, redirect
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+
+from ..models.participant import Participant
+from ..models.boolean_response import BooleanResponse
+
+
+def have_you_ever_smoked(request):
+ try:
+ participant = Participant.objects.get(
+ unique_id=request.session['participant_id'])
+ except Participant.DoesNotExist:
+ return redirect(reverse("questions:start"))
+
+ if request.method == "POST":
+ try:
+ value = int(request.POST['value'])
+
+ if value:
+ BooleanResponse.objects.create(
+ participant=participant,
+ value=value,
+ question="Have you ever smoked?"
+ )
+
+ return redirect(reverse("questions:date_of_birth"))
+ else:
+ return redirect(reverse("questions:non_smoker_exit"))
+
+ except (ValueError, ValidationError):
+ return render(
+ request,
+ "have_you_ever_smoked.jinja",
+ {"participant_id": participant.unique_id},
+ status=422
+ )
+
+ return render(
+ request,
+ "have_you_ever_smoked.jinja",
+ {"participant_id": participant.unique_id}
+ )
diff --git a/lung_cancer_screening/questions/views/non_smoker_exit.py b/lung_cancer_screening/questions/views/non_smoker_exit.py
new file mode 100644
index 00000000..1b2ba4b4
--- /dev/null
+++ b/lung_cancer_screening/questions/views/non_smoker_exit.py
@@ -0,0 +1,7 @@
+from django.shortcuts import render
+
+def non_smoker_exit(request):
+ return render(
+ request,
+ "non_smoker_exit.jinja"
+ )
diff --git a/lung_cancer_screening/questions/views/responses.py b/lung_cancer_screening/questions/views/responses.py
new file mode 100644
index 00000000..045333e7
--- /dev/null
+++ b/lung_cancer_screening/questions/views/responses.py
@@ -0,0 +1,16 @@
+from django.shortcuts import render, redirect
+from django.urls import reverse
+
+from ..models.participant import Participant
+
+def responses(request):
+ try:
+ participant = Participant.objects.get(unique_id=request.session['participant_id'])
+ except Participant.DoesNotExist:
+ return redirect(reverse("questions:start"))
+
+ return render(
+ request,
+ "responses.jinja",
+ {"responses": participant.responses()}
+ )
diff --git a/lung_cancer_screening/questions/views/start.py b/lung_cancer_screening/questions/views/start.py
new file mode 100644
index 00000000..16fd6df5
--- /dev/null
+++ b/lung_cancer_screening/questions/views/start.py
@@ -0,0 +1,28 @@
+from django.shortcuts import render, redirect
+from django.urls import reverse
+from django.core.exceptions import ValidationError
+
+from lung_cancer_screening.questions.models.participant import Participant
+
+def start(request):
+ if request.method == "POST":
+ try:
+ participant = Participant.objects.create(
+ unique_id=request.POST['participant_id']
+ )
+
+ request.session['participant_id'] = participant.unique_id
+
+ return redirect(reverse("questions:have_you_ever_smoked"))
+ except ValidationError:
+ return render(
+ request,
+ "start.jinja",
+ status=422
+ )
+
+ else:
+ return render(
+ request,
+ "start.jinja"
+ )
diff --git a/lung_cancer_screening/settings.py b/lung_cancer_screening/settings.py
index d2ac981d..67e9c281 100644
--- a/lung_cancer_screening/settings.py
+++ b/lung_cancer_screening/settings.py
@@ -45,7 +45,8 @@ def boolean_env(key, default=None):
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
- 'lung_cancer_screening.core'
+ 'lung_cancer_screening.core',
+ 'lung_cancer_screening.questions'
]
MIDDLEWARE = [
diff --git a/lung_cancer_screening/urls.py b/lung_cancer_screening/urls.py
index 816a7198..6b4e5287 100644
--- a/lung_cancer_screening/urls.py
+++ b/lung_cancer_screening/urls.py
@@ -15,10 +15,11 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
-from django.urls import path
-from . import views
+from django.urls import path, include
urlpatterns = [
- path('', views.home, name='home'),
+ path('', include(
+ ("lung_cancer_screening.questions.urls", "questions"),
+ namespace="questions")),
path('admin/', admin.site.urls),
]
diff --git a/poetry.lock b/poetry.lock
index 13e5adb9..9f8a4a9d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -360,6 +360,21 @@ typing-extensions = "*"
[package.extras]
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
[[package]]
name = "ruff"
version = "0.12.11"
@@ -389,6 +404,18 @@ files = [
{file = "ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d"},
]
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
[[package]]
name = "sqlparse"
version = "0.5.3"
@@ -433,4 +460,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">=3.13"
-content-hash = "527b9bc66fafd5cf1cfa76bf91958702732ff431fb3fdc86144ff8dda162d930"
+content-hash = "74f9b6c308feaa6aadb17b9a92b7f8eec6e47868acca90f725cb4b2c88218716"
diff --git a/pyproject.toml b/pyproject.toml
index ff600221..a59bcff6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ dependencies = [
"psycopg2-binary (>=2.9.10,<3.0.0)",
"nhsuk-frontend-jinja (>=0.3.1,<0.4.0)",
"gunicorn (>=23.0.0,<24.0.0)",
+ "python-dateutil (>=2.9.0.post0,<3.0.0)",
]
[tool.poetry]
diff --git a/scripts/tests/unit.sh b/scripts/tests/unit.sh
index eb9223f3..b0dbb05f 100755
--- a/scripts/tests/unit.sh
+++ b/scripts/tests/unit.sh
@@ -17,4 +17,4 @@ cd "$(git rev-parse --show-toplevel)"
# tests from here. If you want to run other test suites, see the predefined
# tasks in scripts/test.mk.
-docker compose run --rm web poetry run python manage.py test
+docker compose run --rm --remove-orphans web poetry run python manage.py test