diff --git a/manage_breast_screening/mammograms/forms/breast_cancer_history_form.py b/manage_breast_screening/mammograms/forms/breast_cancer_history_form.py new file mode 100644 index 000000000..bb2ee0ea1 --- /dev/null +++ b/manage_breast_screening/mammograms/forms/breast_cancer_history_form.py @@ -0,0 +1,206 @@ +from django.db.models import TextChoices +from django.forms import Textarea + +from manage_breast_screening.core.services.auditor import Auditor +from manage_breast_screening.nhsuk_forms.fields.char_field import CharField +from manage_breast_screening.nhsuk_forms.fields.choice_fields import ( + ChoiceField, + MultipleChoiceField, +) +from manage_breast_screening.nhsuk_forms.fields.integer_field import IntegerField +from manage_breast_screening.nhsuk_forms.forms import FormWithConditionalFields +from manage_breast_screening.participants.models.breast_cancer_history_item import ( + BreastCancerHistoryItem, +) + + +class BreastCancerHistoryForm(FormWithConditionalFields): + class DiagnosisLocationChoices(TextChoices): + RIGHT_BREAST = "RIGHT_BREAST", "Right breast" + LEFT_BREAST = "LEFT_BREAST", "Left breast" + DONT_KNOW = "DONT_KNOW", "Don't know" + + diagnosis_location = MultipleChoiceField( + label="In which breasts was cancer diagnosed?", + choices=DiagnosisLocationChoices, + error_messages={"required": "Select which breasts cancer was diagnosed in"}, + exclusive_choices={DiagnosisLocationChoices.DONT_KNOW}, + ) + # todo: constrain min/max + diagnosis_year = IntegerField( + label="Year of diagnosis (optional)", + label_classes="nhsuk-label--m", + classes="nhsuk-input--width-4", + hint="Leave blank if unknown", + required=False, + ) + + right_breast_procedure = ChoiceField( + label="Right breast (or axilla)", + visually_hidden_label_prefix="What procedure have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=BreastCancerHistoryItem.Procedure, + error_messages={ + "required": "Select which procedure they have had in the right breast" + }, + ) + left_breast_procedure = ChoiceField( + label="Left breast (or axilla)", + visually_hidden_label_prefix="What procedure have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=BreastCancerHistoryItem.Procedure, + error_messages={ + "required": "Select which procedure they have had in the left breast" + }, + ) + + right_breast_other_surgery = MultipleChoiceField( + label="Right breast (or axilla)", + visually_hidden_label_prefix="What other surgery have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=BreastCancerHistoryItem.Surgery, + exclusive_choices={BreastCancerHistoryItem.Surgery.NO_SURGERY}, + error_messages={ + "required": "Select any other surgery they have had in the right breast" + }, + ) + left_breast_other_surgery = MultipleChoiceField( + label="Left breast (or axilla)", + visually_hidden_label_prefix="What other surgery have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=BreastCancerHistoryItem.Surgery, + exclusive_choices={BreastCancerHistoryItem.Surgery.NO_SURGERY}, + error_messages={ + "required": "Select any other surgery they have had in the left breast" + }, + ) + + right_breast_treatment = MultipleChoiceField( + label="Right breast (or axilla)", + visually_hidden_label_prefix="What treatment have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=BreastCancerHistoryItem.Treatment, + exclusive_choices={BreastCancerHistoryItem.Treatment.NO_RADIOTHERAPY}, + error_messages={ + "required": "Select what treatment they have had in the right breast" + }, + ) + left_breast_treatment = MultipleChoiceField( + label="Left breast (or axilla)", + visually_hidden_label_prefix="What treatment have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=BreastCancerHistoryItem.Treatment, + exclusive_choices={BreastCancerHistoryItem.Treatment.NO_RADIOTHERAPY}, + error_messages={ + "required": "Select what treatment they have had in the left breast" + }, + ) + + systemic_treatments = MultipleChoiceField( + visually_hidden_label_prefix="What treatment have they had that are ", + visually_hidden_label_suffix="?", + label="Systemic treatments", + label_classes="nhsuk-fieldset__legend--s", + choices=BreastCancerHistoryItem.SystemicTreatment, + exclusive_choices={ + BreastCancerHistoryItem.SystemicTreatment.NO_SYSTEMIC_TREATMENTS + }, + error_messages={"required": "Select what systemic treatments they have had"}, + ) + systemic_treatments_other_treatment_details = CharField( + label="Provide details", + required=False, + error_messages={"required": "Provide details of the other systemic treatment"}, + ) + + intervention_location = ChoiceField( + label="Where did surgery and treatment take place?", + choices=BreastCancerHistoryItem.InterventionLocation, + error_messages={"required": "Select where surgery and treatment took place"}, + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.given_field_value( + "systemic_treatments", BreastCancerHistoryItem.SystemicTreatment.OTHER + ).require_field("systemic_treatments_other_treatment_details") + + for location_value in BreastCancerHistoryItem.InterventionLocation: + self.fields[f"intervention_location_details_{location_value.lower()}"] = ( + CharField( + label="Provide details", + required=False, + error_messages={ + "required": "Provide details about where the surgery and treatment took place" + }, + ) + ) + + self.fields["additional_details"] = CharField( + label="Additional details (optional)", + label_classes="nhsuk-label--m", + hint="Include any other relevant information about the treatment", + required=False, + widget=Textarea({"rows": 3}), + ) + + self.given_field("intervention_location").require_field_with_prefix( + "intervention_location_details" + ) + + def model_values(self): + match self.cleaned_data.get("diagnosis_location", []): + case [ + self.DiagnosisLocationChoices.RIGHT_BREAST, + self.DiagnosisLocationChoices.LEFT_BREAST, + ]: + diagnosis_location = ( + BreastCancerHistoryItem.DiagnosisLocationChoices.BOTH_BREASTS.value + ) + case [other]: + diagnosis_location = other + + location_value = self.cleaned_data["intervention_location"] + + return dict( + diagnosis_location=diagnosis_location, + diagnosis_year=self.cleaned_data.get("diagnosis_year"), + right_breast_procedure=self.cleaned_data.get("right_breast_procedure"), + left_breast_procedure=self.cleaned_data.get("left_breast_procedure"), + right_breast_other_surgery=self.cleaned_data.get( + "right_breast_other_surgery" + ), + left_breast_other_surgery=self.cleaned_data.get( + "left_breast_other_surgery" + ), + right_breast_treatment=self.cleaned_data.get("right_breast_treatment"), + left_breast_treatment=self.cleaned_data.get("left_breast_treatment"), + systemic_treatments=self.cleaned_data.get("systemic_treatments"), + systemic_treatments_other_treatment_details=self.cleaned_data.get( + "systemic_treatments_other_treatment_details" + ), + intervention_location=location_value, + intervention_location_details=self.cleaned_data.get( + f"intervention_location_details_{location_value.lower()}" + ), + additional_details=self.cleaned_data.get("additional_details"), + ) + + def create(self, appointment, request): + auditor = Auditor.from_request(request) + field_values = self.model_values() + + instance = appointment.breast_cancer_history_items.create( + **field_values, + ) + + auditor.audit_create(instance) + + return instance diff --git a/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/breast_cancer_history_item_form.jinja b/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/breast_cancer_history_item_form.jinja new file mode 100644 index 000000000..23b5dd38b --- /dev/null +++ b/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/breast_cancer_history_item_form.jinja @@ -0,0 +1,65 @@ +{% extends "layout-form.jinja" %} +{% from "nhsuk/components/button/macro.jinja" import button %} +{% from "mammograms/medical_information/medical_history/forms/macros.jinja" import diagnosis_location_field %} + +{% block form %} + + {{ diagnosis_location_field(form.diagnosis_location) }} + + {{ form.diagnosis_year.as_field_group() }} + + {# H2 hidden from AT as it's included in the fieldset legend of each set of radios #} + +
+
+ {% do form.right_breast_procedure.add_divider_after("MASTECTOMY_NO_TISSUE_REMAINING", "or") %} + {{ form.right_breast_procedure.as_field_group() }} +
+
+ {% do form.left_breast_procedure.add_divider_after("MASTECTOMY_NO_TISSUE_REMAINING", "or") %} + {{ form.left_breast_procedure.as_field_group() }} +
+
+ + {# H2 hidden from AT as it's included in the fieldset legend of each set of checkboxes #} + + +
+
+ {% do form.right_breast_other_surgery.add_divider_after("SYMMETRISATION", "or") %} + {{ form.right_breast_other_surgery.as_field_group() }} +
+
+ {% do form.left_breast_other_surgery.add_divider_after("SYMMETRISATION", "or") %} + {{ form.left_breast_other_surgery.as_field_group() }} +
+
+ + {# H2 hidden from AT as it's included in the fieldset legend of each set of checkboxes #} + + +
+
+ {% do form.right_breast_treatment.add_divider_after("LYMPH_NODE_RADIOTHERAPY", "or") %} + {{ form.right_breast_treatment.as_field_group() }} +
+
+ {% do form.left_breast_treatment.add_divider_after("LYMPH_NODE_RADIOTHERAPY", "or") %} + {{ form.left_breast_treatment.as_field_group() }} +
+
+ + {% do form.systemic_treatments.add_divider_after("OTHER", "or") %} + {{ form.systemic_treatments.as_field_group() }} + + {{ form.intervention_location.as_field_group() }} + + {{ form.additional_details.as_field_group() }} + +
+ {{ button({ + "text": "Save" + }) }} +
+ +{% endblock %} diff --git a/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/macros.jinja b/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/macros.jinja new file mode 100644 index 000000000..a34204051 --- /dev/null +++ b/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/macros.jinja @@ -0,0 +1,74 @@ +{% from "nhsuk/components/checkboxes/macro.jinja" import checkboxes %} +{% from "nhsuk/components/fieldset/macro.jinja" import fieldset %} + +{# A single field with its options arranged on a grid. #} +{# Taborder is left to right, top to bottom. #} +{# Error messages belong to the field as a whole so are displayed above the grid. #} +{% macro diagnosis_location_field(form_field) %} +
+ {% call fieldset({ + "legend": { + "text": form_field.label, + "classes": "nhsuk-fieldset__legend--m" + } + }) %} + + {% if form_field.errors %} + + Error: {{ form_field.errors | first }} + + {% endif %} + +
+
+ {{ checkboxes({ + "name": form_field.name, + "values": form_field.value(), + "idPrefix": form_field.auto_id, + "formGroup": { + "classes": "nhsuk-u-margin-bottom-2" + }, + "items": [ + { + "value": "RIGHT_BREAST", + "text": "Right breast" + } + ] + }) }} +
+
+ {{ checkboxes({ + "name": form_field.name, + "values": form_field.value(), + "idPrefix": form_field.auto_id ~ "_left", + "formGroup": { + "classes": "nhsuk-u-margin-bottom-2" + }, + "items": [ + { + "value": "LEFT_BREAST", + "text": "Left breast" + } + ] + }) }} +
+
+ + {{ checkboxes({ + "name": form_field.name, + "values": form_field.value(), + "idPrefix": form_field.auto_id ~ "_not_known", + "items": [ + { + "divider": "or" + }, + { + "value": "DONT_KNOW", + "text": "Does not know", + "exclusive": true + } + ] + }) }} + {% endcall %} +
+{% endmacro %} diff --git a/manage_breast_screening/mammograms/jinja2/mammograms/record_medical_information.jinja b/manage_breast_screening/mammograms/jinja2/mammograms/record_medical_information.jinja index 4123f4904..0de679ada 100644 --- a/manage_breast_screening/mammograms/jinja2/mammograms/record_medical_information.jinja +++ b/manage_breast_screening/mammograms/jinja2/mammograms/record_medical_information.jinja @@ -37,6 +37,7 @@ {% for presented_item in presenter.breast_cancer_history %} {{ summaryList(presented_item.summary_list_params) }} {% endfor %} + {{ presenter.add_breast_cancer_history_link.text }}
{% endset %} {% set mastectomy_or_lumpectomy_history_html %} diff --git a/manage_breast_screening/mammograms/presenters/medical_information_presenter.py b/manage_breast_screening/mammograms/presenters/medical_information_presenter.py index 32eaa2a70..5f954722b 100644 --- a/manage_breast_screening/mammograms/presenters/medical_information_presenter.py +++ b/manage_breast_screening/mammograms/presenters/medical_information_presenter.py @@ -150,6 +150,18 @@ def add_other_symptom_link(self): ), } + @property + def add_breast_cancer_history_link(self): + url = reverse( + "mammograms:add_breast_cancer_history_item", + kwargs={"pk": self.appointment.pk}, + ) + + return { + "href": url, + "text": ("Add breast cancer history"), + } + @property def add_implanted_medical_device_history_link(self): url = reverse( diff --git a/manage_breast_screening/mammograms/tests/forms/test_breast_cancer_history_form.py b/manage_breast_screening/mammograms/tests/forms/test_breast_cancer_history_form.py new file mode 100644 index 000000000..d6e467fde --- /dev/null +++ b/manage_breast_screening/mammograms/tests/forms/test_breast_cancer_history_form.py @@ -0,0 +1,177 @@ +from urllib.parse import urlencode + +import pytest +from django.forms import model_to_dict +from django.http import QueryDict +from django.test import RequestFactory + +from manage_breast_screening.mammograms.forms.breast_cancer_history_form import ( + BreastCancerHistoryForm, +) +from manage_breast_screening.participants.tests.factories import AppointmentFactory + + +@pytest.fixture +def appointment(): + return AppointmentFactory() + + +@pytest.fixture +def incoming_request(clinical_user): + request = RequestFactory().get("/test-form") + request.user = clinical_user + return request + + +class TestBreastCancerHistoryForm: + def test_no_data_not_valid(self): + form = BreastCancerHistoryForm(data=QueryDict()) + assert not form.is_valid() + assert form.errors == { + "diagnosis_location": ["Select which breasts cancer was diagnosed in"], + "intervention_location": ["Select where surgery and treatment took place"], + "left_breast_other_surgery": [ + "Select any other surgery they have had in the left breast" + ], + "left_breast_procedure": [ + "Select which procedure they have had in the left breast" + ], + "left_breast_treatment": [ + "Select what treatment they have had in the left breast" + ], + "right_breast_other_surgery": [ + "Select any other surgery they have had in the right breast" + ], + "right_breast_procedure": [ + "Select which procedure they have had in the right breast" + ], + "right_breast_treatment": [ + "Select what treatment they have had in the right breast" + ], + "systemic_treatments": ["Select what systemic treatments they have had"], + } + + def test_valid_form(self): + form = BreastCancerHistoryForm( + data=QueryDict( + urlencode( + { + "diagnosis_location": "RIGHT_BREAST", + "intervention_location": "NHS_HOSPITAL", + "intervention_location_details_nhs_hospital": "abc", + "left_breast_other_surgery": "NO_SURGERY", + "left_breast_procedure": "NO_PROCEDURE", + "left_breast_treatment": "NO_RADIOTHERAPY", + "right_breast_other_surgery": "LYMPH_NODE_SURGERY", + "right_breast_procedure": "LUMPECTOMY", + "right_breast_treatment": "BREAST_RADIOTHERAPY", + "systemic_treatments": "NO_SYSTEMIC_TREATMENTS", + } + ) + ) + ) + + assert form.is_valid(), form.errors + + def test_missing_intervention_location_details(self): + form = BreastCancerHistoryForm( + data=QueryDict( + urlencode( + { + "diagnosis_location": "RIGHT_BREAST", + "intervention_location": "NHS_HOSPITAL", + "left_breast_other_surgery": "NO_SURGERY", + "left_breast_procedure": "NO_PROCEDURE", + "left_breast_treatment": "NO_RADIOTHERAPY", + "right_breast_other_surgery": "LYMPH_NODE_SURGERY", + "right_breast_procedure": "LUMPECTOMY", + "right_breast_treatment": "BREAST_RADIOTHERAPY", + "systemic_treatments": "NO_SYSTEMIC_TREATMENTS", + } + ) + ) + ) + + assert not form.is_valid() + assert form.errors == { + "intervention_location_details_nhs_hospital": [ + "Provide details about where the surgery and treatment took place" + ], + } + + def test_missing_systemic_treatments_other_treatment_details(self): + form = BreastCancerHistoryForm( + data=QueryDict( + urlencode( + { + "diagnosis_location": "RIGHT_BREAST", + "intervention_location": "NHS_HOSPITAL", + "intervention_location_details_nhs_hospital": "abc", + "left_breast_other_surgery": "NO_SURGERY", + "left_breast_procedure": "NO_PROCEDURE", + "left_breast_treatment": "NO_RADIOTHERAPY", + "right_breast_other_surgery": "LYMPH_NODE_SURGERY", + "right_breast_procedure": "LUMPECTOMY", + "right_breast_treatment": "BREAST_RADIOTHERAPY", + "systemic_treatments": "OTHER", + } + ) + ) + ) + + assert not form.is_valid() + assert form.errors == { + "systemic_treatments_other_treatment_details": [ + "Provide details of the other systemic treatment" + ] + } + + @pytest.mark.django_db + def test_create(self, appointment, incoming_request): + form = BreastCancerHistoryForm( + data=QueryDict( + urlencode( + { + "diagnosis_location": "RIGHT_BREAST", + "intervention_location": "NHS_HOSPITAL", + "intervention_location_details_nhs_hospital": "abc", + "left_breast_other_surgery": "NO_SURGERY", + "left_breast_procedure": "NO_PROCEDURE", + "left_breast_treatment": "NO_RADIOTHERAPY", + "right_breast_other_surgery": "LYMPH_NODE_SURGERY", + "right_breast_procedure": "LUMPECTOMY", + "right_breast_treatment": "BREAST_RADIOTHERAPY", + "systemic_treatments": "NO_SYSTEMIC_TREATMENTS", + } + ) + ) + ) + assert form.is_valid() + instance = form.create(appointment, incoming_request) + + assert model_to_dict(instance) == { + "additional_details": "", + "appointment": appointment.pk, + "diagnosis_location": "RIGHT_BREAST", + "diagnosis_year": None, + "intervention_location": "NHS_HOSPITAL", + "intervention_location_details": "abc", + "left_breast_other_surgery": [ + "NO_SURGERY", + ], + "left_breast_procedure": "NO_PROCEDURE", + "left_breast_treatment": [ + "NO_RADIOTHERAPY", + ], + "right_breast_other_surgery": [ + "LYMPH_NODE_SURGERY", + ], + "right_breast_procedure": "LUMPECTOMY", + "right_breast_treatment": [ + "BREAST_RADIOTHERAPY", + ], + "systemic_treatments": [ + "NO_SYSTEMIC_TREATMENTS", + ], + "systemic_treatments_other_treatment_details": "", + } diff --git a/manage_breast_screening/mammograms/tests/presenters/test_breast_cancer_history_item_presenter.py b/manage_breast_screening/mammograms/tests/presenters/test_breast_cancer_history_item_presenter.py index 05de1d485..797d5bfc1 100644 --- a/manage_breast_screening/mammograms/tests/presenters/test_breast_cancer_history_item_presenter.py +++ b/manage_breast_screening/mammograms/tests/presenters/test_breast_cancer_history_item_presenter.py @@ -44,7 +44,7 @@ def test_single(self): "text": "Other surgery", }, "value": { - "html": "Right breast: No surgery
Left breast: No surgery", + "html": "Right breast: No other surgery
Left breast: No other surgery", }, }, { diff --git a/manage_breast_screening/mammograms/tests/presenters/test_medical_information_presenter.py b/manage_breast_screening/mammograms/tests/presenters/test_medical_information_presenter.py index fef0481c1..a55c2eed1 100644 --- a/manage_breast_screening/mammograms/tests/presenters/test_medical_information_presenter.py +++ b/manage_breast_screening/mammograms/tests/presenters/test_medical_information_presenter.py @@ -114,6 +114,16 @@ def test_add_nipple_change_link(self): "text": "Add another nipple change", } + def test_add_breast_cancer_history_link(self): + appointment = AppointmentFactory() + + assert MedicalInformationPresenter( + appointment + ).add_breast_cancer_history_link == { + "href": f"/mammograms/{appointment.pk}/record-medical-information/breast-cancer-history/", + "text": "Add breast cancer history", + } + def test_implanted_medical_device_history_link(self): appointment = AppointmentFactory() diff --git a/manage_breast_screening/mammograms/tests/views/test_breast_cancer_history_views.py b/manage_breast_screening/mammograms/tests/views/test_breast_cancer_history_views.py new file mode 100644 index 000000000..6f4a8811b --- /dev/null +++ b/manage_breast_screening/mammograms/tests/views/test_breast_cancer_history_views.py @@ -0,0 +1,92 @@ +import pytest +from django.contrib import messages +from django.urls import reverse +from pytest_django.asserts import assertInHTML, assertMessages, assertRedirects + +from manage_breast_screening.participants.tests.factories import AppointmentFactory + + +@pytest.mark.django_db +class TestBreastCancerHistoryViews: + def test_renders_response(self, clinical_user_client): + appointment = AppointmentFactory.create( + clinic_slot__clinic__setting__provider=clinical_user_client.current_provider + ) + response = clinical_user_client.http.get( + reverse( + "mammograms:add_breast_cancer_history_item", + kwargs={"pk": appointment.pk}, + ) + ) + assert response.status_code == 200 + + def test_valid_post_redirects_to_appointment(self, clinical_user_client): + appointment = AppointmentFactory.create( + clinic_slot__clinic__setting__provider=clinical_user_client.current_provider + ) + response = clinical_user_client.http.post( + reverse( + "mammograms:add_breast_cancer_history_item", + kwargs={"pk": appointment.pk}, + ), + { + "diagnosis_location": "RIGHT_BREAST", + "intervention_location": "NHS_HOSPITAL", + "intervention_location_details_nhs_hospital": "abc", + "left_breast_other_surgery": "NO_SURGERY", + "left_breast_procedure": "NO_PROCEDURE", + "left_breast_treatment": "NO_RADIOTHERAPY", + "right_breast_other_surgery": "LYMPH_NODE_SURGERY", + "right_breast_procedure": "LUMPECTOMY", + "right_breast_treatment": "BREAST_RADIOTHERAPY", + "systemic_treatments": "NO_SYSTEMIC_TREATMENTS", + }, + ) + + assertRedirects( + response, + reverse( + "mammograms:record_medical_information", + kwargs={"pk": appointment.pk}, + ), + ) + assertMessages( + response, + [ + messages.Message( + level=messages.SUCCESS, + message="Breast cancer history added", + ) + ], + ) + + def test_invalid_post_renders_response_with_errors(self, clinical_user_client): + appointment = AppointmentFactory.create( + clinic_slot__clinic__setting__provider=clinical_user_client.current_provider + ) + + response = clinical_user_client.http.post( + reverse( + "mammograms:add_breast_cancer_history_item", + kwargs={"pk": appointment.pk}, + ), + {}, + ) + + assert response.status_code == 200 + assertInHTML( + """ + + """, + response.text, + ) diff --git a/manage_breast_screening/mammograms/urls.py b/manage_breast_screening/mammograms/urls.py index d7460b7d2..0388a4574 100644 --- a/manage_breast_screening/mammograms/urls.py +++ b/manage_breast_screening/mammograms/urls.py @@ -3,6 +3,7 @@ from .views import ( appointment_views, breast_augmentation_history_view, + breast_cancer_history_views, cyst_history_view, implanted_medical_device_history_view, special_appointment_views, @@ -122,6 +123,11 @@ symptom_views.DeleteSymptomView.as_view(), name="delete_symptom", ), + path( + "/record-medical-information/breast-cancer-history/", + breast_cancer_history_views.AddBreastCancerHistoryView.as_view(), + name="add_breast_cancer_history_item", + ), path( "/record-medical-information/implanted-medical-device-history/", implanted_medical_device_history_view.AddImplantedMedicalDeviceHistoryView.as_view(), diff --git a/manage_breast_screening/mammograms/views/breast_cancer_history_views.py b/manage_breast_screening/mammograms/views/breast_cancer_history_views.py new file mode 100644 index 000000000..9841efbbc --- /dev/null +++ b/manage_breast_screening/mammograms/views/breast_cancer_history_views.py @@ -0,0 +1,55 @@ +from django.contrib import messages +from django.urls import reverse +from django.views.generic import FormView + +from manage_breast_screening.mammograms.forms.breast_cancer_history_form import ( + BreastCancerHistoryForm, +) + +from .mixins import InProgressAppointmentMixin + + +class AddBreastCancerHistoryView(InProgressAppointmentMixin, FormView): + form_class = BreastCancerHistoryForm + template_name = "mammograms/medical_information/medical_history/forms/breast_cancer_history_item_form.jinja" + + def form_valid(self, form): + form.create(appointment=self.appointment, request=self.request) + + messages.add_message( + self.request, + messages.SUCCESS, + "Breast cancer history added", + ) + + return super().form_valid(form) + + def get_success_url(self): + return reverse( + "mammograms:record_medical_information", kwargs={"pk": self.appointment.pk} + ) + + def get_back_link_params(self): + return { + "href": reverse( + "mammograms:record_medical_information", + kwargs={"pk": self.appointment_pk}, + ), + "text": "Back", + } + + def get_context_data(self, **kwargs): + context = super().get_context_data() + + participant = self.appointment.participant + + context.update( + { + "back_link_params": self.get_back_link_params(), + "caption": participant.full_name, + "heading": "Add details of breast cancer", + "page_title": "Add details of breast cancer", + }, + ) + + return context diff --git a/manage_breast_screening/nhsuk_forms/fields/choice_fields.py b/manage_breast_screening/nhsuk_forms/fields/choice_fields.py index 04474529f..78841fcb6 100644 --- a/manage_breast_screening/nhsuk_forms/fields/choice_fields.py +++ b/manage_breast_screening/nhsuk_forms/fields/choice_fields.py @@ -1,6 +1,7 @@ from django import forms from django.forms import widgets +from manage_breast_screening.nhsuk_forms.forms import FormWithConditionalFields from manage_breast_screening.nhsuk_forms.validators import ExcludesOtherOptionsValidator @@ -34,7 +35,14 @@ def add_conditional_html(self, value, html): self._conditional_html[value] = html def conditional_html(self, value): - return self._conditional_html.get(value) + explicitly_set_html = self._conditional_html.get(value) + if explicitly_set_html: + return explicitly_set_html + + if isinstance(self.form, FormWithConditionalFields): + return self.form.conditionally_shown_html(self.name, value) + + return None def add_divider_after(self, previous, divider): self.dividers[previous] = divider diff --git a/manage_breast_screening/nhsuk_forms/forms.py b/manage_breast_screening/nhsuk_forms/forms.py index 0c6b2d863..6ba83b2bb 100644 --- a/manage_breast_screening/nhsuk_forms/forms.py +++ b/manage_breast_screening/nhsuk_forms/forms.py @@ -46,7 +46,7 @@ def require_field(self, conditionally_required_field): ) -class ConditionalFieldValidator: +class ConditionalFieldDeclarations: """ Helper class to perform the conditional validation for the FormWithConditionalFields """ @@ -103,6 +103,15 @@ def clean_conditional_fields(self): ), ) + def conditionally_required_fields(self, predicate_field, predicate_field_value): + matches = [ + requirement + for requirement in self.conditional_requirements + if requirement.predicate_field == predicate_field + and requirement.predicate_field_value == predicate_field_value + ] + return matches + class FormWithConditionalFields(Form): """ @@ -116,10 +125,29 @@ class FormWithConditionalFields(Form): """ def __init__(self, *args, **kwargs): - self.conditional_field_validator = ConditionalFieldValidator(self) + self.conditional_field_declarations = ConditionalFieldDeclarations(self) super().__init__(*args, **kwargs) + def conditionally_shown_html(self, field, value): + """ + If a single field is conditionally shown when `field` is set to `value`, + then return the HTML for that field. + + If there is no conditional logic, return None. + + If there are multiple conditional fields that get shown, also return None, + (it is up to the template to decide how to combine them). + """ + conditional_fields = ( + self.conditional_field_declarations.conditionally_required_fields( + field, value + ) + ) + if len(conditional_fields) == 1: + field_name = conditional_fields[0].conditionally_required_field + return self[field_name].as_field_group() + def given_field_value(self, field, field_value): """ Mini-DSL to declare conditional field relationships @@ -127,7 +155,7 @@ def given_field_value(self, field, field_value): e.g. self.given_field_value('foo', 'choice1').require_field('other_details') """ return FieldValuePredicate( - self.conditional_field_validator, field=field, field_value=field_value + self.conditional_field_declarations, field=field, field_value=field_value ) def given_field(self, predicate_field): @@ -137,7 +165,7 @@ def given_field(self, predicate_field): e.g. self.given_field('foo').require_field_with_prefix('other') """ return FieldPredicate( - self.conditional_field_validator, + self.conditional_field_declarations, field_name=predicate_field, field_choices=self.fields[predicate_field].choices, ) @@ -148,10 +176,10 @@ def clean_conditional_fields(self): This can happen when the user selects one option, fills out the conditional field, and then changes to a different option. """ - return self.conditional_field_validator.clean_conditional_fields() + return self.conditional_field_declarations.clean_conditional_fields() def full_clean(self): - for requirement in self.conditional_field_validator.conditional_requirements: + for requirement in self.conditional_field_declarations.conditional_requirements: field_name = requirement.conditionally_required_field predicate_field_values = self.data.getlist(requirement.predicate_field) if not predicate_field_values: diff --git a/manage_breast_screening/nhsuk_forms/tests/fields/test_choice_fields.py b/manage_breast_screening/nhsuk_forms/tests/fields/test_choice_fields.py index 629c33d59..7d5689870 100644 --- a/manage_breast_screening/nhsuk_forms/tests/fields/test_choice_fields.py +++ b/manage_breast_screening/nhsuk_forms/tests/fields/test_choice_fields.py @@ -8,6 +8,7 @@ CheckboxSelectMultipleWithoutFieldset, RadioSelectWithoutFieldset, ) +from manage_breast_screening.nhsuk_forms.forms import FormWithConditionalFields from ...fields import CharField, ChoiceField, MultipleChoiceField @@ -40,6 +41,23 @@ class TestForm(Form): return TestForm + @pytest.fixture + def conditional_form_class(self): + class TestForm(FormWithConditionalFields): + predicate = ChoiceField( + label="Abc", + label_classes="app-abc", + choices=(("a", "A"), ("b", "B")), + hint="Pick either one", + ) + details = CharField() + + def __init__(self): + super().__init__() + self.given_field_value("predicate", "b").require_field("details") + + return TestForm + def test_renders_nhs_radios(self, form_class): assertHTMLEqual( form_class()["field"].as_field_group(), @@ -67,7 +85,37 @@ def test_renders_nhs_radios(self, form_class): """, ) - def test_renders_radios_with_conditional_html(self, form_class): + def test_renders_radios_with_conditional_field(self, conditional_form_class): + form = conditional_form_class() + + assertHTMLEqual( + form["predicate"].as_field_group(), + """ +
+
+ Abc +
Pick either one
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ """, + ) + + def test_renders_radios_with_explicitly_set_conditional_html(self, form_class): form = form_class() form["field"].add_conditional_html("b", "

Hello

") diff --git a/manage_breast_screening/nhsuk_forms/tests/test_forms.py b/manage_breast_screening/nhsuk_forms/tests/test_forms.py index 292ea0a7a..994463c6b 100644 --- a/manage_breast_screening/nhsuk_forms/tests/test_forms.py +++ b/manage_breast_screening/nhsuk_forms/tests/test_forms.py @@ -5,10 +5,9 @@ from django.db.models import TextChoices from django.forms import CharField, CheckboxSelectMultiple, ChoiceField, IntegerField from django.http import QueryDict +from pytest_django.asserts import assertHTMLEqual -from manage_breast_screening.nhsuk_forms.fields.choice_fields import ( - MultipleChoiceField, -) +from manage_breast_screening.nhsuk_forms.fields.choice_fields import MultipleChoiceField from manage_breast_screening.nhsuk_forms.fields.split_date_field import SplitDateField from manage_breast_screening.nhsuk_forms.forms import FormWithConditionalFields @@ -284,6 +283,20 @@ def test_simple_conditional_predicate_missing(self, FormWithSimpleConditional): assert not form.is_valid() assert form.errors == {"foo": ["This field is required."]} + def test_simple_conditional_rendering(self, FormWithSimpleConditional): + form = FormWithSimpleConditional() + + assertHTMLEqual( + form.conditionally_shown_html("foo", "a"), + """ + + + + + """, + ) + assert form.conditionally_shown_html("foo", "b") is None + def test_per_value_conditional_missing_value( self, FormWithConditionalFieldsPerValue ): diff --git a/manage_breast_screening/participants/migrations/0045_alter_breastcancerhistoryitem_left_breast_other_surgery_and_more.py b/manage_breast_screening/participants/migrations/0045_alter_breastcancerhistoryitem_left_breast_other_surgery_and_more.py new file mode 100644 index 000000000..20abd274a --- /dev/null +++ b/manage_breast_screening/participants/migrations/0045_alter_breastcancerhistoryitem_left_breast_other_surgery_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-11-20 11:01 + +import django.contrib.postgres.fields +import manage_breast_screening.nhsuk_forms.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [('participants', '0044_alter_breastaugmentationhistoryitem_implants_have_been_removed_and_more')] + + operations = [ + migrations.AlterField( + model_name='breastcancerhistoryitem', + name='left_breast_other_surgery', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('LYMPH_NODE_SURGERY', 'Lymph node surgery'), ('RECONSTRUCTION', 'Reconstruction'), ('SYMMETRISATION', 'Symmetrisation'), ('NO_SURGERY', 'No other surgery')]), default=list, size=None, validators=[manage_breast_screening.nhsuk_forms.validators.ExcludesOtherOptionsValidator('NO_SURGERY', 'No other surgery')]), + ), + migrations.AlterField( + model_name='breastcancerhistoryitem', + name='right_breast_other_surgery', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('LYMPH_NODE_SURGERY', 'Lymph node surgery'), ('RECONSTRUCTION', 'Reconstruction'), ('SYMMETRISATION', 'Symmetrisation'), ('NO_SURGERY', 'No other surgery')]), default=list, size=None, validators=[manage_breast_screening.nhsuk_forms.validators.ExcludesOtherOptionsValidator('NO_SURGERY', 'No other surgery')]), + ), + ] diff --git a/manage_breast_screening/participants/migrations/max_migration.txt b/manage_breast_screening/participants/migrations/max_migration.txt index 323df5e69..3d9b37a95 100644 --- a/manage_breast_screening/participants/migrations/max_migration.txt +++ b/manage_breast_screening/participants/migrations/max_migration.txt @@ -1 +1 @@ -0044_alter_breastaugmentationhistoryitem_implants_have_been_removed_and_more +0045_alter_breastcancerhistoryitem_left_breast_other_surgery_and_more diff --git a/manage_breast_screening/participants/models/breast_cancer_history_item.py b/manage_breast_screening/participants/models/breast_cancer_history_item.py index afc21e38f..df015d11c 100644 --- a/manage_breast_screening/participants/models/breast_cancer_history_item.py +++ b/manage_breast_screening/participants/models/breast_cancer_history_item.py @@ -30,7 +30,7 @@ class Surgery(models.TextChoices): LYMPH_NODE_SURGERY = "LYMPH_NODE_SURGERY", "Lymph node surgery" RECONSTRUCTION = "RECONSTRUCTION", "Reconstruction" SYMMETRISATION = "SYMMETRISATION", "Symmetrisation" - NO_SURGERY = "NO_SURGERY", "No surgery" + NO_SURGERY = "NO_SURGERY", "No other surgery" class Treatment(models.TextChoices): BREAST_RADIOTHERAPY = "BREAST_RADIOTHERAPY", "Breast radiotherapy" diff --git a/manage_breast_screening/participants/tests/models/test_breast_cancer_history_item.py b/manage_breast_screening/participants/tests/models/test_breast_cancer_history_item.py index 02b6df929..844d3f6f5 100644 --- a/manage_breast_screening/participants/tests/models/test_breast_cancer_history_item.py +++ b/manage_breast_screening/participants/tests/models/test_breast_cancer_history_item.py @@ -24,7 +24,7 @@ def test_invalid_no_surgery(self): with pytest.raises( ValidationError, - match=r"\['Unselect \"No surgery\" in order to select other options'\]", + match=r"\['Unselect \"No other surgery\" in order to select other options'\]", ): item.full_clean() diff --git a/manage_breast_screening/tests/system/clinical/test_breast_cancer_history.py b/manage_breast_screening/tests/system/clinical/test_breast_cancer_history.py new file mode 100644 index 000000000..398b79088 --- /dev/null +++ b/manage_breast_screening/tests/system/clinical/test_breast_cancer_history.py @@ -0,0 +1,161 @@ +from django.urls import reverse +from playwright.sync_api import expect + +from manage_breast_screening.participants.tests.factories import ( + AppointmentFactory, + ParticipantFactory, + ScreeningEpisodeFactory, +) + +from ..system_test_setup import SystemTestCase + + +class TestBreastCancerHistory(SystemTestCase): + def test_adding_breast_cancer_history(self): + self.given_i_am_logged_in_as_a_clinical_user() + self.and_there_is_an_appointment() + self.and_i_am_on_the_record_medical_information_page() + self.when_i_click_on_add_breast_cancer_history() + self.then_i_see_the_breast_cancer_history_form() + + self.when_i_select_right_breast() + self.and_i_select_lumpectomy_in_right_breast() + self.and_i_select_no_surgery_in_left_breast() + self.and_i_select_no_other_surgery() + self.and_i_select_no_radiotherapy() + self.and_i_select_chemotherapy() + self.and_i_select_a_private_clinic() + self.and_i_enter_the_details_of_the_private_clinic() + + self.and_i_click_save() + self.then_i_am_back_on_the_medical_information_page() + self.and_the_history_item_is_added() + self.and_the_message_says_device_added() + + def test_accessibility(self): + self.given_i_am_logged_in_as_a_clinical_user() + self.and_there_is_an_appointment() + self.and_i_am_on_the_record_medical_information_page() + self.when_i_click_on_add_breast_cancer_history() + self.then_the_accessibility_baseline_is_met() + + def test_validation_errors(self): + self.given_i_am_logged_in_as_a_clinical_user() + self.and_there_is_an_appointment() + self.and_i_am_on_the_record_medical_information_page() + self.when_i_click_on_add_breast_cancer_history() + self.and_i_click_save() + self.then_i_am_prompted_to_fill_in_required_fields() + + def and_there_is_an_appointment(self): + self.participant = ParticipantFactory(first_name="Janet", last_name="Williams") + self.screening_episode = ScreeningEpisodeFactory(participant=self.participant) + self.appointment = AppointmentFactory( + screening_episode=self.screening_episode, + clinic_slot__clinic__setting__provider=self.current_provider, + ) + + def and_i_am_on_the_record_medical_information_page(self): + self.page.goto( + self.live_server_url + + reverse( + "mammograms:record_medical_information", + kwargs={"pk": self.appointment.pk}, + ) + ) + + def when_i_click_on_add_breast_cancer_history(self): + self.page.get_by_text("Add breast cancer history").click() + + def then_i_see_the_breast_cancer_history_form(self): + expect(self.page.get_by_text("Add details of breast cancer")).to_be_visible() + self.assert_page_title_contains("Add details of breast cancer") + + def when_i_select_right_breast(self): + fieldset = self.get_fieldset_by_legend("In which breasts was cancer diagnosed?") + fieldset.get_by_label("Right breast").click() + + def and_i_click_save(self): + self.page.get_by_text("Save").click() + + def then_i_am_back_on_the_medical_information_page(self): + self.expect_url("mammograms:record_medical_information", pk=self.appointment.pk) + + def and_the_history_item_is_added(self): + key = self.page.locator( + ".nhsuk-summary-list__key", + has=self.page.get_by_text("Breast cancer history", exact=True), + ) + row = self.page.locator(".nhsuk-summary-list__row").filter(has=key) + expect(row).to_contain_text("Right breast") + + def and_the_message_says_device_added(self): + alert = self.page.get_by_role("alert") + + expect(alert).to_contain_text("Success") + expect(alert).to_contain_text("Breast cancer history added") + + def and_i_select_lumpectomy_in_right_breast(self): + fieldset = self.get_fieldset_by_legend( + "What procedure have they had in their Right breast (or axilla)?" + ) + fieldset.get_by_label("Lumpectomy").first.click() + + def and_i_select_no_surgery_in_left_breast(self): + fieldset = self.get_fieldset_by_legend( + "What procedure have they had in their Left breast (or axilla)?" + ) + fieldset.get_by_label("No procedure").last.click() + + def and_i_select_no_other_surgery(self): + fieldset = self.get_fieldset_by_legend( + "What other surgery have they had in their Right breast (or axilla)?" + ) + fieldset.get_by_label("No other surgery").click() + + fieldset = self.get_fieldset_by_legend( + "What other surgery have they had in their Left breast (or axilla)?" + ) + fieldset.get_by_label("No other surgery").click() + + def and_i_select_no_radiotherapy(self): + fieldset = self.get_fieldset_by_legend( + "What treatment have they had in their Right breast (or axilla)?" + ) + fieldset.get_by_label("No radiotherapy").click() + + fieldset = self.get_fieldset_by_legend( + "What treatment have they had in their Left breast (or axilla)?" + ) + fieldset.get_by_label("No radiotherapy").click() + + def and_i_select_chemotherapy(self): + fieldset = self.get_fieldset_by_legend("Systemic treatments") + fieldset.get_by_label("Chemotherapy").click() + + def and_i_select_a_private_clinic(self): + fieldset = self.get_fieldset_by_legend( + "Where did surgery and treatment take place?" + ) + fieldset.get_by_label("At a private clinic in the UK").click() + + def and_i_enter_the_details_of_the_private_clinic(self): + fieldset = self.get_fieldset_by_legend( + "Where did surgery and treatment take place?" + ) + fieldset.get_by_label("Provide details").filter(visible=True).fill("Abc clinic") + + def then_i_am_prompted_to_fill_in_required_fields(self): + self.expect_validation_error( + error_text="Select which breasts cancer was diagnosed in", + fieldset_legend="In which breasts was cancer diagnosed?", + field_label="Right breast", + field_name="diagnosis_location", + ) + + self.expect_validation_error( + error_text="Select which procedure they have had in the right breast", + fieldset_legend="What procedure have they had in their Right breast (or axilla)", + field_label="Lumpectomy", + field_name="right_breast_procedure", + ) diff --git a/manage_breast_screening/tests/system/system_test_setup.py b/manage_breast_screening/tests/system/system_test_setup.py index 447255c37..3db52386e 100644 --- a/manage_breast_screening/tests/system/system_test_setup.py +++ b/manage_breast_screening/tests/system/system_test_setup.py @@ -155,3 +155,8 @@ def assert_page_title_contains(self, component): assert components[-1] == "NHS", components assert components[-2] == "Manage breast screening", components assert components[-3] == component, components + + def get_fieldset_by_legend(self, legend): + return self.page.locator("fieldset").filter( + has=self.page.locator("legend", has_text=legend) + )