diff --git a/manage_breast_screening/data/west_tester_today_clinic_data.yml b/manage_breast_screening/data/west_tester_today_clinic_data.yml index 9049f79a6..dd545f19d 100644 --- a/manage_breast_screening/data/west_tester_today_clinic_data.yml +++ b/manage_breast_screening/data/west_tester_today_clinic_data.yml @@ -175,7 +175,7 @@ clinic: right_breast_other_surgery: - RECONSTRUCTION left_breast_other_surgery: - - NO_SURGERY + - NO_OTHER_SURGERY year_of_surgery: 2018 surgery_reason: RISK_REDUCTION additional_details: Right mastectomy with reconstruction following genetic testing diff --git a/manage_breast_screening/mammograms/forms/__init__.py b/manage_breast_screening/mammograms/forms/__init__.py index b12a1ed3e..50aa348db 100644 --- a/manage_breast_screening/mammograms/forms/__init__.py +++ b/manage_breast_screening/mammograms/forms/__init__.py @@ -3,6 +3,7 @@ from .breast_augmentation_history_form import BreastAugmentationHistoryForm from .cyst_history_form import CystHistoryForm from .implanted_medical_device_history_form import ImplantedMedicalDeviceHistoryForm +from .mastectomy_or_lumpectomy_history_form import MastectomyOrLumpectomyHistoryForm from .record_medical_information_form import RecordMedicalInformationForm from .screening_appointment_form import ScreeningAppointmentForm from .special_appointment_forms import ( @@ -19,5 +20,6 @@ "ScreeningAppointmentForm", "ProvideSpecialAppointmentDetailsForm", "MarkReasonsTemporaryForm", + "MastectomyOrLumpectomyHistoryForm", "ImplantedMedicalDeviceHistoryForm", ] diff --git a/manage_breast_screening/mammograms/forms/mastectomy_or_lumpectomy_history_form.py b/manage_breast_screening/mammograms/forms/mastectomy_or_lumpectomy_history_form.py new file mode 100644 index 000000000..fe5d312b1 --- /dev/null +++ b/manage_breast_screening/mammograms/forms/mastectomy_or_lumpectomy_history_form.py @@ -0,0 +1,134 @@ +from django.forms import widgets +from django.forms.widgets import RadioSelect, Textarea + +from manage_breast_screening.core.services.auditor import Auditor +from manage_breast_screening.nhsuk_forms.fields import ( + CharField, + ChoiceField, + YearField, +) +from manage_breast_screening.nhsuk_forms.fields.choice_fields import ( + MultipleChoiceField, +) +from manage_breast_screening.nhsuk_forms.forms import FormWithConditionalFields +from manage_breast_screening.participants.models.mastectomy_or_lumpectomy_history_item import ( + MastectomyOrLumpectomyHistoryItem, +) + + +class MastectomyOrLumpectomyHistoryForm(FormWithConditionalFields): + right_breast_procedure = ChoiceField( + label="Right breast", + visually_hidden_label_prefix="What procedure have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + widget=RadioSelect, + choices=MastectomyOrLumpectomyHistoryItem.Procedure, + error_messages={ + "required": "Select which procedure they have had in the right breast", + }, + ) + left_breast_procedure = ChoiceField( + label="Left breast", + visually_hidden_label_prefix="What procedure have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + widget=RadioSelect, + choices=MastectomyOrLumpectomyHistoryItem.Procedure, + error_messages={ + "required": "Select which procedure they have had in the left breast", + }, + ) + right_breast_other_surgery = MultipleChoiceField( + label="Right breast", + visually_hidden_label_prefix="What other surgery have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=MastectomyOrLumpectomyHistoryItem.Surgery, + error_messages={ + "required": "Select any other surgery they have had in the right breast", + }, + exclusive_choices={"NO_OTHER_SURGERY"}, + ) + left_breast_other_surgery = MultipleChoiceField( + label="Left breast", + visually_hidden_label_prefix="What other surgery have they had in their ", + visually_hidden_label_suffix="?", + label_classes="nhsuk-fieldset__legend--s", + choices=MastectomyOrLumpectomyHistoryItem.Surgery, + error_messages={ + "required": "Select any other surgery they have had in the left breast", + }, + exclusive_choices={"NO_OTHER_SURGERY"}, + ) + year_of_surgery = YearField( + hint="Leave blank if unknown", + required=False, + label="Year of surgery (optional)", + label_classes="nhsuk-label--m", + classes="nhsuk-input--width-4", + ) + surgery_reason = ChoiceField( + choices=MastectomyOrLumpectomyHistoryItem.SurgeryReason, + label="Why was this surgery done?", + widget=widgets.RadioSelect, + error_messages={"required": "Select the reason for surgery"}, + ) + surgery_other_reason_details = CharField( + required=False, + label="Provide details", + error_messages={"required": "Provide details of the surgery"}, + classes="nhsuk-u-width-two-thirds", + ) + additional_details = CharField( + hint="Include any other relevant information about the surgery", + required=False, + label="Additional details (optional)", + label_classes="nhsuk-label--m", + widget=Textarea(attrs={"rows": 4}), + max_words=500, + error_messages={"max_words": "Additional details must be 500 words or less"}, + ) + + def __init__(self, *args, participant, **kwargs): + super().__init__(*args, **kwargs) + + self.given_field_value( + "surgery_reason", + MastectomyOrLumpectomyHistoryItem.SurgeryReason.OTHER_REASON, + ).require_field("surgery_other_reason_details") + + def model_values(self): + return dict( + left_breast_procedure=self.cleaned_data.get("left_breast_procedure", None), + right_breast_procedure=self.cleaned_data.get( + "right_breast_procedure", None + ), + left_breast_other_surgery=self.cleaned_data.get( + "left_breast_other_surgery", [] + ), + right_breast_other_surgery=self.cleaned_data.get( + "right_breast_other_surgery", [] + ), + year_of_surgery=self.cleaned_data.get("year_of_surgery", None), + surgery_reason=self.cleaned_data.get("surgery_reason", None), + surgery_other_reason_details=self.cleaned_data.get( + "surgery_other_reason_details", "" + ), + additional_details=self.cleaned_data.get("additional_details", ""), + ) + + def create(self, appointment, request): + auditor = Auditor.from_request(request) + field_values = self.model_values() + + mastectomy_or_lumpectomy_history = ( + appointment.mastectomy_or_lumpectomy_history_items.create( + appointment=appointment, + **field_values, + ) + ) + + auditor.audit_create(mastectomy_or_lumpectomy_history) + + return mastectomy_or_lumpectomy_history diff --git a/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/mastectomy_or_lumpectomy_history.jinja b/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/mastectomy_or_lumpectomy_history.jinja new file mode 100644 index 000000000..9891c13bd --- /dev/null +++ b/manage_breast_screening/mammograms/jinja2/mammograms/medical_information/medical_history/forms/mastectomy_or_lumpectomy_history.jinja @@ -0,0 +1,51 @@ +{% extends "layout-form.jinja" %} +{% from "nhsuk/components/button/macro.jinja" import button %} +{% from "nhsuk/components/fieldset/macro.jinja" import fieldset %} + + +{% block form %} +

+ This is designed to capture information on prophylactic or elective surgery. + + Add breast cancer details + + if that was the reason for this surgery. +

+ + +
+
+ {% 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() }} +
+
+ + +
+
+ {% 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() }} +
+
+ + {{ form.year_of_surgery.as_field_group() }} + + {{ form.surgery_reason.as_field_group() }} + + {{ form.additional_details.as_field_group() }} + +
+ {{ button({ + "text": "Save" + }) }} +
+{% endblock %} + 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 3f101df5d..1717e481e 100644 --- a/manage_breast_screening/mammograms/jinja2/mammograms/record_medical_information.jinja +++ b/manage_breast_screening/mammograms/jinja2/mammograms/record_medical_information.jinja @@ -44,6 +44,7 @@ {% for presented_item in presenter.mastectomy_or_lumpectomy_history %} {{ summaryList(presented_item.summary_list_params) }} {% endfor %} + {{ presenter.add_mastectomy_or_lumpectomy_history_link.text }}
{% endset %} {% set implanted_medical_device_history_html %} diff --git a/manage_breast_screening/mammograms/presenters/mastectomy_or_lumpectomy_history_item_presenter.py b/manage_breast_screening/mammograms/presenters/mastectomy_or_lumpectomy_history_item_presenter.py index 279996561..aac1954b4 100644 --- a/manage_breast_screening/mammograms/presenters/mastectomy_or_lumpectomy_history_item_presenter.py +++ b/manage_breast_screening/mammograms/presenters/mastectomy_or_lumpectomy_history_item_presenter.py @@ -25,6 +25,7 @@ def __init__(self, mastectomy_or_lumpectomy_history_item): else "Not specified" ) self.surgery_reason = self._item.get_surgery_reason_display() + self.surgery_other_reason_details = self._item.surgery_other_reason_details self.additional_details = nl2br(self._item.additional_details) @property @@ -61,6 +62,10 @@ def summary_list_params(self): "key": {"text": "Surgery reason"}, "value": {"html": self.surgery_reason}, }, + { + "key": {"text": "Surgery other reason details"}, + "value": {"text": self.surgery_other_reason_details}, + }, { "key": {"text": "Additional details"}, "value": {"html": self.additional_details}, diff --git a/manage_breast_screening/mammograms/presenters/medical_information_presenter.py b/manage_breast_screening/mammograms/presenters/medical_information_presenter.py index 464b72d13..c70520bd1 100644 --- a/manage_breast_screening/mammograms/presenters/medical_information_presenter.py +++ b/manage_breast_screening/mammograms/presenters/medical_information_presenter.py @@ -209,3 +209,15 @@ def add_benign_lump_history_link(self): "href": url, "text": "Add benign lump history", } + + @property + def add_mastectomy_or_lumpectomy_history_link(self): + url = reverse( + "mammograms:add_mastectomy_or_lumpectomy_history_item", + kwargs={"pk": self.appointment.pk}, + ) + + return { + "href": url, + "text": "Add mastectomy or lumpectomy history", + } diff --git a/manage_breast_screening/mammograms/tests/forms/test_mastectomy_or_lumpectomy_history_form.py b/manage_breast_screening/mammograms/tests/forms/test_mastectomy_or_lumpectomy_history_form.py new file mode 100644 index 000000000..55dd2aad1 --- /dev/null +++ b/manage_breast_screening/mammograms/tests/forms/test_mastectomy_or_lumpectomy_history_form.py @@ -0,0 +1,358 @@ +import datetime +from urllib.parse import urlencode + +import pytest +from django.http import QueryDict +from django.test import RequestFactory + +from manage_breast_screening.core.models import AuditLog +from manage_breast_screening.participants.models.mastectomy_or_lumpectomy_history_item import ( + MastectomyOrLumpectomyHistoryItem, +) +from manage_breast_screening.participants.tests.factories import AppointmentFactory + +from ...forms.mastectomy_or_lumpectomy_history_form import ( + MastectomyOrLumpectomyHistoryForm, +) + + +@pytest.mark.django_db +class TestMastectomyOrLumpectomyHistoryItemForm: + def test_missing_required_fields(self, clinical_user): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict(), participant=appointment.participant + ) + + assert not form.is_valid() + assert form.errors == { + "right_breast_procedure": [ + "Select which procedure they have had in the right breast", + ], + "left_breast_procedure": [ + "Select which procedure they have had in the left breast", + ], + "right_breast_other_surgery": [ + "Select any other surgery they have had in the right breast", + ], + "left_breast_other_surgery": [ + "Select any other surgery they have had in the left breast", + ], + "surgery_reason": ["Select the reason for surgery"], + } + + def test_right_breast_other_surgery_no_other_surgery_and_others( + self, clinical_user + ): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict( + urlencode( + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION, + MastectomyOrLumpectomyHistoryItem.Surgery.SYMMETRISATION, + MastectomyOrLumpectomyHistoryItem.Surgery.NO_OTHER_SURGERY, + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION, + }, + doseq=True, + ), + ), + participant=appointment.participant, + ) + + assert not form.is_valid() + assert form.errors == { + "right_breast_other_surgery": [ + 'Unselect "No other surgery" in order to select other options' + ], + } + + def test_left_breast_other_surgery_no_other_surgery_and_others(self, clinical_user): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict( + urlencode( + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION, + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION, + MastectomyOrLumpectomyHistoryItem.Surgery.NO_OTHER_SURGERY, + ], + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION, + }, + doseq=True, + ), + ), + participant=appointment.participant, + ) + + assert not form.is_valid() + assert form.errors == { + "left_breast_other_surgery": [ + 'Unselect "No other surgery" in order to select other options' + ], + } + + @pytest.mark.parametrize( + "year_of_surgery", + [ + -1, + 1900, + datetime.date.today().year - 81, + datetime.date.today().year + 1, + 3000, + ], + ) + def test_year_of_surgery_outside_range(self, clinical_user, year_of_surgery): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict( + urlencode( + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "year_of_surgery": year_of_surgery, + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION, + }, + doseq=True, + ), + ), + participant=appointment.participant, + ) + + assert not form.is_valid() + assert form.errors == { + "year_of_surgery": [ + self.create_year_outside_range_error_messsage(year_of_surgery) + ], + } + + def test_year_of_surgery_invalid(self, clinical_user): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict( + urlencode( + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "year_of_surgery": "invalid value for year_of_surgery", + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION, + }, + doseq=True, + ), + ), + participant=appointment.participant, + ) + + assert not form.is_valid() + assert form.errors == { + "year_of_surgery": ["Enter a whole number."], + } + + def test_other_reason_without_details(self, clinical_user): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict( + urlencode( + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.OTHER_REASON, + }, + doseq=True, + ), + ), + participant=appointment.participant, + ) + + assert not form.is_valid() + assert form.errors == { + "surgery_other_reason_details": ["Provide details of the surgery"], + } + + def test_other_reason_details_when_not_other_reason(self, clinical_user): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict( + urlencode( + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.MASTECTOMY_TISSUE_REMAINING, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION, + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.SYMMETRISATION, + ], + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION, + "surgery_other_reason_details": "surgery other details", + }, + doseq=True, + ), + ), + participant=appointment.participant, + ) + + assert form.is_valid() + + obj = form.create(appointment=appointment, request=request) + obj.refresh_from_db() + + assert obj.appointment == appointment + assert ( + obj.right_breast_procedure + == MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY + ) + assert ( + obj.left_breast_procedure + == MastectomyOrLumpectomyHistoryItem.Procedure.MASTECTOMY_TISSUE_REMAINING + ) + assert obj.right_breast_other_surgery == [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION, + ] + assert obj.left_breast_other_surgery == [ + MastectomyOrLumpectomyHistoryItem.Surgery.SYMMETRISATION, + ] + assert obj.year_of_surgery is None + assert ( + obj.surgery_reason + == MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION + ) + assert obj.surgery_other_reason_details == "" + assert obj.additional_details == "" + + @pytest.mark.parametrize( + "data", + [ + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.MASTECTOMY_TISSUE_REMAINING, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.MASTECTOMY_NO_TISSUE_REMAINING, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION, + MastectomyOrLumpectomyHistoryItem.Surgery.SYMMETRISATION, + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.NO_OTHER_SURGERY, + ], + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.RISK_REDUCTION, + }, + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.NO_PROCEDURE, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.NO_OTHER_SURGERY + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION, + MastectomyOrLumpectomyHistoryItem.Surgery.SYMMETRISATION, + ], + "year_of_surgery": datetime.date.today().year - 80, + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION, + }, + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "year_of_surgery": datetime.date.today().year, + "surgery_reason": MastectomyOrLumpectomyHistoryItem.SurgeryReason.OTHER_REASON, + "surgery_other_reason_details": "surgery other details", + "additional_details": "additional details", + }, + ], + ) + def test_success(self, clinical_user, data): + appointment = AppointmentFactory() + request = RequestFactory().get("/test-form") + request.user = clinical_user + + form = MastectomyOrLumpectomyHistoryForm( + QueryDict(urlencode(data, doseq=True)), + participant=appointment.participant, + ) + + assert form.is_valid() + + existing_log_count = AuditLog.objects.count() + obj = form.create(appointment=appointment, request=request) + assert AuditLog.objects.count() == existing_log_count + 1 + audit_log = AuditLog.objects.filter( + object_id=obj.pk, operation=AuditLog.Operations.CREATE + ).first() + assert audit_log.actor == clinical_user + + obj.refresh_from_db() + assert obj.appointment == appointment + assert obj.right_breast_procedure == data.get("right_breast_procedure") + assert obj.left_breast_procedure == data.get("left_breast_procedure") + assert obj.right_breast_other_surgery == data.get("right_breast_other_surgery") + assert obj.left_breast_other_surgery == data.get("left_breast_other_surgery") + assert obj.year_of_surgery == data.get("year_of_surgery", None) + assert obj.surgery_reason == data.get("surgery_reason", "") + assert obj.surgery_other_reason_details == data.get( + "surgery_other_reason_details", "" + ) + assert obj.additional_details == data.get("additional_details", "") + + def create_year_outside_range_error_messsage(self, request_year): + max_year = datetime.date.today().year + min_year = max_year - 80 + return ( + (f"Year must be {max_year} or earlier") + if request_year > max_year + else (f"Year must be {min_year} or later") + ) diff --git a/manage_breast_screening/mammograms/tests/presenters/test_mastectomy_or_lumpectomy_history_item_presenter.py b/manage_breast_screening/mammograms/tests/presenters/test_mastectomy_or_lumpectomy_history_item_presenter.py index 4a0121c52..65783a21a 100644 --- a/manage_breast_screening/mammograms/tests/presenters/test_mastectomy_or_lumpectomy_history_item_presenter.py +++ b/manage_breast_screening/mammograms/tests/presenters/test_mastectomy_or_lumpectomy_history_item_presenter.py @@ -18,7 +18,7 @@ def test_single(self): MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION ], left_breast_other_surgery=[ - MastectomyOrLumpectomyHistoryItem.Surgery.NO_SURGERY + MastectomyOrLumpectomyHistoryItem.Surgery.NO_OTHER_SURGERY ], year_of_surgery=2018, surgery_reason=MastectomyOrLumpectomyHistoryItem.SurgeryReason.RISK_REDUCTION, @@ -42,7 +42,7 @@ def test_single(self): "text": "Other surgery", }, "value": { - "html": "Right breast: Reconstruction
Left breast: No surgery", + "html": "Right breast: Reconstruction
Left breast: No other surgery", }, }, { @@ -61,6 +61,14 @@ def test_single(self): "html": "Risk reduction", }, }, + { + "key": { + "text": "Surgery other reason details", + }, + "value": { + "text": "", + }, + }, { "key": { "text": "Additional details", 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 5fc6db8c8..124c38cdd 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 @@ -161,3 +161,13 @@ def test_add_benign_lump_history_link(self): "href": f"/mammograms/{appointment.pk}/record-medical-information/benign-lump-history/", "text": "Add benign lump history", } + + def test_mastectomy_or_lumpectomy_history_link(self): + appointment = AppointmentFactory() + + assert MedicalInformationPresenter( + appointment + ).add_mastectomy_or_lumpectomy_history_link == { + "href": f"/mammograms/{appointment.pk}/record-medical-information/mastectomy-or-lumpectomy-history/", + "text": "Add mastectomy or lumpectomy history", + } diff --git a/manage_breast_screening/mammograms/tests/views/test_mastectomy_or_lumpectomy_history_views.py b/manage_breast_screening/mammograms/tests/views/test_mastectomy_or_lumpectomy_history_views.py new file mode 100644 index 000000000..24ca28908 --- /dev/null +++ b/manage_breast_screening/mammograms/tests/views/test_mastectomy_or_lumpectomy_history_views.py @@ -0,0 +1,91 @@ +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.models.mastectomy_or_lumpectomy_history_item import ( + MastectomyOrLumpectomyHistoryItem, +) +from manage_breast_screening.participants.tests.factories import ( + AppointmentFactory, +) + + +@pytest.mark.django_db +class TestAddMastectomyOrLumpectomyHistoryView: + 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_mastectomy_or_lumpectomy_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_mastectomy_or_lumpectomy_history_item", + kwargs={"pk": appointment.pk}, + ), + { + "right_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "left_breast_procedure": MastectomyOrLumpectomyHistoryItem.Procedure.LUMPECTOMY, + "right_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "left_breast_other_surgery": [ + MastectomyOrLumpectomyHistoryItem.Surgery.RECONSTRUCTION + ], + "surgery_reason": [ + MastectomyOrLumpectomyHistoryItem.SurgeryReason.GENDER_AFFIRMATION + ], + }, + ) + assertRedirects( + response, + reverse( + "mammograms:record_medical_information", + kwargs={"pk": appointment.pk}, + ), + ) + assertMessages( + response, + [ + messages.Message( + level=messages.SUCCESS, + message="Details of mastectomy or lumpectomy 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_mastectomy_or_lumpectomy_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 014284ffb..121624002 100644 --- a/manage_breast_screening/mammograms/urls.py +++ b/manage_breast_screening/mammograms/urls.py @@ -7,6 +7,7 @@ breast_cancer_history_views, cyst_history_view, implanted_medical_device_history_view, + mastectomy_or_lumpectomy_history_view, special_appointment_views, symptom_views, ) @@ -149,4 +150,9 @@ benign_lump_history_item_views.AddBenignLumpHistoryItemView.as_view(), name="add_benign_lump_history_item", ), + path( + "/record-medical-information/mastectomy-or-lumpectomy-history/", + mastectomy_or_lumpectomy_history_view.AddMastectomyOrLumpectomyHistoryView.as_view(), + name="add_mastectomy_or_lumpectomy_history_item", + ), ] diff --git a/manage_breast_screening/mammograms/views/mastectomy_or_lumpectomy_history_view.py b/manage_breast_screening/mammograms/views/mastectomy_or_lumpectomy_history_view.py new file mode 100644 index 000000000..eef6180c7 --- /dev/null +++ b/manage_breast_screening/mammograms/views/mastectomy_or_lumpectomy_history_view.py @@ -0,0 +1,71 @@ +from functools import cached_property + +from django.contrib import messages +from django.urls import reverse +from django.views.generic import FormView + +from manage_breast_screening.mammograms.presenters.medical_information_presenter import ( + MedicalInformationPresenter, +) + +from ..forms.mastectomy_or_lumpectomy_history_form import ( + MastectomyOrLumpectomyHistoryForm, +) +from .mixins import InProgressAppointmentMixin + + +class AddMastectomyOrLumpectomyHistoryView(InProgressAppointmentMixin, FormView): + form_class = MastectomyOrLumpectomyHistoryForm + template_name = "mammograms/medical_information/medical_history/forms/mastectomy_or_lumpectomy_history.jinja" + + def get_success_url(self): + return reverse( + "mammograms:record_medical_information", kwargs={"pk": self.appointment.pk} + ) + + def form_valid(self, form): + form.create(appointment=self.appointment, request=self.request) + + messages.add_message( + self.request, + messages.SUCCESS, + "Details of mastectomy or lumpectomy added", + ) + + return super().form_valid(form) + + def get_back_link_params(self): + return { + "href": reverse( + "mammograms:record_medical_information", + kwargs={"pk": self.appointment_pk}, + ), + "text": "Back to appointment", + } + + 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, + "participant_first_name": participant.first_name, + "heading": "Add details of mastectomy or lumpectomy", + "page_title": "Add details of mastectomy or lumpectomy", + "presenter": MedicalInformationPresenter(self.appointment), + }, + ) + + return context + + @cached_property + def participant(self): + return self.appointment.participant + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["participant"] = self.participant + return kwargs diff --git a/manage_breast_screening/participants/migrations/0046_mastectomyorlumpectomyhistoryitem_surgery_other_reason_details_and_more.py b/manage_breast_screening/participants/migrations/0046_mastectomyorlumpectomyhistoryitem_surgery_other_reason_details_and_more.py new file mode 100644 index 000000000..0957e0903 --- /dev/null +++ b/manage_breast_screening/participants/migrations/0046_mastectomyorlumpectomyhistoryitem_surgery_other_reason_details_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2025-11-24 14:34 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('participants', '0045_alter_breastcancerhistoryitem_left_breast_other_surgery_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='mastectomyorlumpectomyhistoryitem', + name='surgery_other_reason_details', + field=models.CharField(blank=True, default=''), + ), + migrations.AlterField( + model_name='mastectomyorlumpectomyhistoryitem', + name='left_breast_other_surgery', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('RECONSTRUCTION', 'Reconstruction'), ('SYMMETRISATION', 'Symmetrisation'), ('NO_OTHER_SURGERY', 'No other surgery')]), default=list, size=None), + ), + migrations.AlterField( + model_name='mastectomyorlumpectomyhistoryitem', + name='right_breast_other_surgery', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('RECONSTRUCTION', 'Reconstruction'), ('SYMMETRISATION', 'Symmetrisation'), ('NO_OTHER_SURGERY', 'No other surgery')]), default=list, size=None), + ), + ] diff --git a/manage_breast_screening/participants/migrations/max_migration.txt b/manage_breast_screening/participants/migrations/max_migration.txt index 3d9b37a95..41cc43c67 100644 --- a/manage_breast_screening/participants/migrations/max_migration.txt +++ b/manage_breast_screening/participants/migrations/max_migration.txt @@ -1 +1 @@ -0045_alter_breastcancerhistoryitem_left_breast_other_surgery_and_more +0046_mastectomyorlumpectomyhistoryitem_surgery_other_reason_details_and_more diff --git a/manage_breast_screening/participants/models/mastectomy_or_lumpectomy_history_item.py b/manage_breast_screening/participants/models/mastectomy_or_lumpectomy_history_item.py index cd6f410b3..91d0b3c9b 100644 --- a/manage_breast_screening/participants/models/mastectomy_or_lumpectomy_history_item.py +++ b/manage_breast_screening/participants/models/mastectomy_or_lumpectomy_history_item.py @@ -21,7 +21,7 @@ class Procedure(models.TextChoices): class Surgery(models.TextChoices): RECONSTRUCTION = "RECONSTRUCTION", "Reconstruction" SYMMETRISATION = "SYMMETRISATION", "Symmetrisation" - NO_SURGERY = "NO_SURGERY", "No surgery" + NO_OTHER_SURGERY = "NO_OTHER_SURGERY", "No other surgery" class SurgeryReason(models.TextChoices): RISK_REDUCTION = "RISK_REDUCTION", "Risk reduction" @@ -43,6 +43,9 @@ class SurgeryReason(models.TextChoices): base_field=models.CharField(choices=Surgery), default=list, ) + # TODO should we rename year_of_surgery to surgery_year + # to be more consitent with names used elsewhere. e.g. procedure_year year_of_surgery = models.IntegerField(null=True, blank=True) surgery_reason = models.CharField(choices=SurgeryReason) + surgery_other_reason_details = models.CharField(blank=True, null=False, default="") additional_details = models.TextField(blank=True, null=False, default="") diff --git a/manage_breast_screening/participants/tests/factories.py b/manage_breast_screening/participants/tests/factories.py index fc1d8ef5d..32267ebe0 100644 --- a/manage_breast_screening/participants/tests/factories.py +++ b/manage_breast_screening/participants/tests/factories.py @@ -189,8 +189,12 @@ class Meta: appointment = SubFactory(AppointmentFactory) right_breast_procedure = MastectomyOrLumpectomyHistoryItem.Procedure.NO_PROCEDURE left_breast_procedure = MastectomyOrLumpectomyHistoryItem.Procedure.NO_PROCEDURE - right_breast_other_surgery = [MastectomyOrLumpectomyHistoryItem.Surgery.NO_SURGERY] - left_breast_other_surgery = [MastectomyOrLumpectomyHistoryItem.Surgery.NO_SURGERY] + right_breast_other_surgery = [ + MastectomyOrLumpectomyHistoryItem.Surgery.NO_OTHER_SURGERY + ] + left_breast_other_surgery = [ + MastectomyOrLumpectomyHistoryItem.Surgery.NO_OTHER_SURGERY + ] year_of_surgery = None surgery_reason = MastectomyOrLumpectomyHistoryItem.SurgeryReason.OTHER_REASON additional_details = "" diff --git a/manage_breast_screening/tests/system/clinical/test_mastectomy_or_lumpectomy_history.py b/manage_breast_screening/tests/system/clinical/test_mastectomy_or_lumpectomy_history.py new file mode 100644 index 000000000..ae25710ba --- /dev/null +++ b/manage_breast_screening/tests/system/clinical/test_mastectomy_or_lumpectomy_history.py @@ -0,0 +1,159 @@ +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 TestRecordingMastectomyOrLumpectomy(SystemTestCase): + def test_adding_mastectomy_or_lumpectomy(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_mastectomy_or_lumpectomy() + self.then_i_see_the_add_mastectomy_or_lumpectomy_form() + + self.when_i_click_save() + self.then_i_see_validation_errors_for_missing_mastectomy_or_lumpectomy_details() + + self.when_i_provide_mastectomy_or_lumpectomy_details() + self.and_i_click_save() + self.then_i_am_back_on_the_medical_information_page() + self.and_the_mastectomy_or_lumpectomy_is_listed() + self.and_the_message_says_mastectomy_or_lumpectomy_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_mastectomy_or_lumpectomy() + self.then_the_accessibility_baseline_is_met() + + 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_mastectomy_or_lumpectomy(self): + self.page.get_by_text("Add mastectomy or lumpectomy history").click() + + def then_i_see_the_add_mastectomy_or_lumpectomy_form(self): + expect( + self.page.get_by_text("Add details of mastectomy or lumpectomy") + ).to_be_visible() + expect( + self.page.get_by_text( + "Add breast cancer details if that was the reason for this surgery." + ) + ).to_be_visible() + link = self.page.get_by_role( + "link", name="Add breast cancer details" + ).get_attribute("href") + assert link == reverse( + "mammograms:add_breast_cancer_history_item", + kwargs={"pk": self.appointment.pk}, + ) + + def then_i_see_validation_errors_for_missing_mastectomy_or_lumpectomy_details(self): + self.expect_validation_error( + error_text="Select which procedure they have had in the right breast", + fieldset_legend="Right breast", + field_label="Lumpectomy", + ) + self.expect_validation_error( + error_text="Select which procedure they have had in the left breast", + fieldset_legend="Left breast", + field_label="Lumpectomy", + ) + self.expect_validation_error( + error_text="Select any other surgery they have had in the right breast", + fieldset_legend="Right breast", + field_label="Reconstruction", + ) + self.expect_validation_error( + error_text="Select any other surgery they have had in the left breast", + fieldset_legend="Left breast", + field_label="Reconstruction", + ) + self.expect_validation_error( + error_text="Select the reason for surgery", + fieldset_legend="Why was this surgery done?", + field_label="Risk reduction", + ) + + def when_i_provide_mastectomy_or_lumpectomy_details(self): + self.page.locator( + "input[name='right_breast_procedure'] + label", has_text="No procedure" + ).click() + self.page.locator( + "input[name='left_breast_procedure'] + label", + has_text="Mastectomy (no tissue remaining)", + ).click() + self.page.locator( + "input[name='right_breast_other_surgery'] + label", + has_text="Reconstruction", + ).click() + self.page.locator( + "input[name='right_breast_other_surgery'] + label", + has_text="Symmetrisation", + ).click() + self.page.locator( + "input[name='left_breast_other_surgery'] + label", + has_text="No other surgery", + ).click() + self.page.get_by_label("Year of surgery (optional)", exact=True).fill("2000") + self.page.get_by_label("Other reason", exact=True).click() + self.page.get_by_label("Provide details", exact=True).fill( + "Other reason for surgery text" + ) + self.page.get_by_label("Additional details (optional)", exact=True).fill( + "additional details for test of mastectomy or lumpectomy details" + ) + + def and_i_click_save(self): + self.page.get_by_text("Save").click() + + when_i_click_save = and_i_click_save + + 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_mastectomy_or_lumpectomy_is_listed(self): + key = self.page.locator( + ".nhsuk-summary-list__key", + has=self.page.get_by_text("Mastectomy or lumpectomy history", exact=True), + ) + row = self.page.locator(".nhsuk-summary-list__row").filter(has=key) + expect(row).to_contain_text("Right breast: No procedure") + expect(row).to_contain_text("Left breast: Mastectomy (no tissue remaining)") + expect(row).to_contain_text("Right breast: Reconstruction, Symmetrisation") + expect(row).to_contain_text("Left breast: No other surgery") + expect(row).to_contain_text("2000") + expect(row).to_contain_text("Other reason") + expect(row).to_contain_text("Other reason for surgery text") + expect(row).to_contain_text( + "additional details for test of mastectomy or lumpectomy details" + ) + + def and_the_message_says_mastectomy_or_lumpectomy_added(self): + alert = self.page.get_by_role("alert") + + expect(alert).to_contain_text("Success") + expect(alert).to_contain_text("Details of mastectomy or lumpectomy added")