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 %} +
+
+
+ {{ csrf_input }} + + {{ dateInput({ + "fieldset": { + "legend": { + "text": "What is your date of birth?", + "classes": "nhsuk-label--l", + "isPageHeading": true + } + }, + "hint": { + "text": "For example, 15 3 1984" + }, + "items": [ + { + "name": "day", + "classes": "nhsuk-input--width-2" + }, + { + "name": "month", + "classes": "nhsuk-input--width-2" + }, + { + "name": "year", + "classes": "nhsuk-input--width-4" + } + ] + }) }} + + + + {{ button({ + "text": "Continue" + }) }} +
+
+
+{% 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 %} +
+
+
+ {{ csrf_input }} + + {{ radios({ + "name": "value", + "fieldset": { + "legend": { + "text": "Have you ever smoked?", + "classes": "nhsuk-fieldset__legend--l", + "isPageHeading": true + } + }, + "items": [ + { + "value": "1", + "text": "Yes" + }, + { + "value": "0", + "text": "No" + } + ] + }) }} + + + + {{ button({ + "text": "Continue" + }) }} +
+
+
+{% 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

+ +
+
+{% 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 %} +
+
+
+ {{ csrf_input }} + + {{ input({ + "label": { + "text": "Enter your unique participant ID" + }, + "id": "participant_id", + "name": "participant_id" + }) }} + + {{ button({ + "text": "Start now" + }) }} +
+
+
+{% 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