diff --git a/manage_breast_screening/mammograms/forms/__init__.py b/manage_breast_screening/mammograms/forms/__init__.py index f2e5fbc21..773232cfd 100644 --- a/manage_breast_screening/mammograms/forms/__init__.py +++ b/manage_breast_screening/mammograms/forms/__init__.py @@ -1,4 +1,5 @@ from .appointment_cannot_go_ahead_form import AppointmentCannotGoAheadForm +from .appointment_note_form import AppointmentNoteForm from .ask_for_medical_information_form import AskForMedicalInformationForm from .breast_augmentation_history_form import BreastAugmentationHistoryForm from .cyst_history_form import CystHistoryForm @@ -15,6 +16,7 @@ __all__ = [ "AppointmentCannotGoAheadForm", "AskForMedicalInformationForm", + "AppointmentNoteForm", "BreastAugmentationHistoryForm", "CystHistoryForm", "RecordMedicalInformationForm", diff --git a/manage_breast_screening/mammograms/forms/appointment_note_form.py b/manage_breast_screening/mammograms/forms/appointment_note_form.py new file mode 100644 index 000000000..a63da9018 --- /dev/null +++ b/manage_breast_screening/mammograms/forms/appointment_note_form.py @@ -0,0 +1,29 @@ +from django import forms +from django.forms import Textarea + +from manage_breast_screening.nhsuk_forms.fields import CharField + + +class AppointmentNoteForm(forms.Form): + content = CharField( + label="Note", + hint="Include information that is relevant to this appointment.", + required=True, + error_messages={ + "required": "Enter a note", + }, + label_classes="nhsuk-label--m", + widget=Textarea(attrs={"rows": 5}), + ) + + def __init__(self, *args, **kwargs): + self.instance = kwargs.pop("instance", None) + if self.instance: + initial = kwargs.setdefault("initial", {}) + initial.setdefault("content", self.instance.content) + super().__init__(*args, **kwargs) + + def save(self): + self.instance.content = self.cleaned_data["content"] + self.instance.save() + return self.instance diff --git a/manage_breast_screening/mammograms/jinja2/mammograms/show/appointment_note.jinja b/manage_breast_screening/mammograms/jinja2/mammograms/show/appointment_note.jinja new file mode 100644 index 000000000..6e90d1475 --- /dev/null +++ b/manage_breast_screening/mammograms/jinja2/mammograms/show/appointment_note.jinja @@ -0,0 +1,84 @@ +{% extends 'layout-app.jinja' %} +{% from 'nhsuk/components/back-link/macro.jinja' import backLink %} +{% from 'nhsuk/components/button/macro.jinja' import button %} +{% from 'nhsuk/components/inset-text/macro.jinja' import insetText %} +{% from 'components/appointment-status/macro.jinja' import appointment_status %} +{% from 'components/appointment-header/macro.jinja' import appointment_header %} +{% from 'components/check-in/macro.jinja' import check_in %} +{% from 'components/secondary-navigation/macro.jinja' import app_secondary_navigation %} +{% from 'mammograms/special_appointments/special_appointment_banner.jinja' import special_appointment_banner %} +{% from 'django_form_helpers.jinja' import form_error_summary %} + +{% block beforeContent %} +{{ backLink({ + "href": presented_appointment.clinic_url, + "text": "Back to clinic" +}) }} +{% endblock beforeContent %} + +{% block page_content %} +
+
+ + {{ form_error_summary(form) }} + +
+

+ + {{ caption }} + + {{ heading }} +

+ +
+ {{ appointment_status(presented_appointment) }} +

+ {{ check_in( + presented_appointment, + check_in_url=url( + 'mammograms:check_in', + kwargs={'pk': presented_appointment.pk} + ), + csrf_input=csrf_input + ) }} +

+
+
+ + {{ appointment_header(request.user, presented_appointment, csrf_input=csrf_input) }} + + {{ special_appointment_banner(presented_appointment.special_appointment, show_change_link=presented_appointment.active) }} + + {{ app_secondary_navigation({ + "visuallyHiddenTitle": "Secondary menu", + "items": secondary_nav_items + }) }} + +
+
+ + {% set inset_html %} +

+ If adjustments are required, + + provide special appointment details instead + +

+ {% endset %} + {{ insetText({ + "html": inset_html + }) }} + +
+ {{ csrf_input }} + {{ form.content.as_field_group() }} + +
+ {{ button({ + "text": "Save note" + }) }} +
+
+
+
+{% endblock %} diff --git a/manage_breast_screening/mammograms/presenters/__init__.py b/manage_breast_screening/mammograms/presenters/__init__.py index 03d1b2c96..27335d707 100644 --- a/manage_breast_screening/mammograms/presenters/__init__.py +++ b/manage_breast_screening/mammograms/presenters/__init__.py @@ -41,7 +41,7 @@ def present_secondary_nav(pk, current_tab=None): { "id": "note", "text": "Note", - "href": "#", + "href": reverse("mammograms:appointment_note", kwargs={"pk": pk}), "current": current_tab == "note", }, ] diff --git a/manage_breast_screening/mammograms/tests/views/test_appointment_views.py b/manage_breast_screening/mammograms/tests/views/test_appointment_views.py index a3532b6ac..903705593 100644 --- a/manage_breast_screening/mammograms/tests/views/test_appointment_views.py +++ b/manage_breast_screening/mammograms/tests/views/test_appointment_views.py @@ -3,6 +3,7 @@ from pytest_django.asserts import assertContains, assertRedirects from manage_breast_screening.core.models import AuditLog +from manage_breast_screening.participants.models import AppointmentNote from manage_breast_screening.participants.tests.factories import AppointmentFactory @@ -21,6 +22,60 @@ def test_renders_response(self, clinical_user_client): assert response.status_code == 200 +@pytest.mark.django_db +class TestAppointmentNoteView: + @pytest.mark.parametrize( + "client_fixture", ["clinical_user_client", "administrative_user_client"] + ) + def test_users_can_save_note(self, request, client_fixture): + client = request.getfixturevalue(client_fixture) + appointment = AppointmentFactory.create( + clinic_slot__clinic__setting__provider=client.current_provider + ) + + note_content = "Participant prefers left arm blood pressure readings." + response = client.http.post( + reverse( + "mammograms:appointment_note", + kwargs={"pk": appointment.pk}, + ), + {"content": note_content}, + ) + + assertRedirects( + response, + reverse("mammograms:appointment_note", kwargs={"pk": appointment.pk}), + ) + saved_note = AppointmentNote.objects.get(appointment=appointment) + assert saved_note.content == note_content + + @pytest.mark.parametrize( + "client_fixture", ["clinical_user_client", "administrative_user_client"] + ) + def test_users_can_update_note(self, request, client_fixture): + client = request.getfixturevalue(client_fixture) + appointment = AppointmentFactory.create( + clinic_slot__clinic__setting__provider=client.current_provider + ) + note = AppointmentNote.objects.create( + appointment=appointment, content="Original note" + ) + + updated_content = "Updated note content" + response = client.http.post( + reverse("mammograms:appointment_note", kwargs={"pk": appointment.pk}), + {"content": updated_content}, + ) + + assertRedirects( + response, + reverse("mammograms:appointment_note", kwargs={"pk": appointment.pk}), + ) + updated_note = AppointmentNote.objects.get(pk=note.pk) + assert updated_note.content == updated_content + assert AppointmentNote.objects.count() == 1 + + @pytest.mark.django_db class TestConfirmIdentity: def test_renders_response(self, clinical_user_client): diff --git a/manage_breast_screening/mammograms/urls.py b/manage_breast_screening/mammograms/urls.py index 860244ee7..a9a30ef2f 100644 --- a/manage_breast_screening/mammograms/urls.py +++ b/manage_breast_screening/mammograms/urls.py @@ -36,6 +36,11 @@ appointment_views.ParticipantDetails.as_view(), name="participant_details", ), + path( + "/note/", + appointment_views.AppointmentNoteView.as_view(), + name="appointment_note", + ), path( "/confirm-identity/", appointment_views.ConfirmIdentity.as_view(), diff --git a/manage_breast_screening/mammograms/views/appointment_views.py b/manage_breast_screening/mammograms/views/appointment_views.py index 520912e0a..60a790240 100644 --- a/manage_breast_screening/mammograms/views/appointment_views.py +++ b/manage_breast_screening/mammograms/views/appointment_views.py @@ -13,6 +13,7 @@ ) from manage_breast_screening.participants.models import ( Appointment, + AppointmentNote, Participant, ParticipantReportedMammogram, ) @@ -20,6 +21,7 @@ from ..forms import ( AppointmentCannotGoAheadForm, + AppointmentNoteForm, AskForMedicalInformationForm, RecordMedicalInformationForm, ) @@ -79,11 +81,6 @@ def get(self, request, *args, **kwargs): class ParticipantDetails(AppointmentMixin, View): - """ - Show a completed appointment. Redirects to the start screening form - if the apppointment is in progress. - """ - template_name = "mammograms/show.jinja" def get(self, request, *args, **kwargs): @@ -118,6 +115,49 @@ def get(self, request, *args, **kwargs): ) +class AppointmentNoteView(AppointmentMixin, FormView): + template_name = "mammograms/show/appointment_note.jinja" + form_class = AppointmentNoteForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + appointment = self.appointment + appointment_presenter = AppointmentPresenter( + appointment, tab_description="Note" + ) + + context.update( + { + "heading": appointment_presenter.participant.full_name, + "caption": appointment_presenter.caption, + "page_title": appointment_presenter.page_title, + "presented_appointment": appointment_presenter, + "secondary_nav_items": present_secondary_nav( + appointment.pk, current_tab="note" + ), + } + ) + return context + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + try: + kwargs["instance"] = self.appointment.note + except AppointmentNote.DoesNotExist: + kwargs["instance"] = AppointmentNote(appointment=self.appointment) + return kwargs + + def form_valid(self, form): + is_new_note = form.instance._state.adding + note = form.save() + auditor = Auditor.from_request(self.request) + if is_new_note: + auditor.audit_create(note) + else: + auditor.audit_update(note) + return redirect("mammograms:appointment_note", pk=self.appointment.pk) + + class ConfirmIdentity(InProgressAppointmentMixin, TemplateView): template_name = "mammograms/confirm_identity.jinja" diff --git a/manage_breast_screening/nonprod/management/commands/seed_demo_data.py b/manage_breast_screening/nonprod/management/commands/seed_demo_data.py index 79a2f97ac..3a2630f0e 100644 --- a/manage_breast_screening/nonprod/management/commands/seed_demo_data.py +++ b/manage_breast_screening/nonprod/management/commands/seed_demo_data.py @@ -22,6 +22,7 @@ ) from manage_breast_screening.participants.models import ( Appointment, + AppointmentNote, AppointmentStatus, BenignLumpHistoryItem, BreastAugmentationHistoryItem, @@ -304,6 +305,7 @@ def create_reported_mammograms(self, participant, mammograms): return participant_mammogram def reset_db(self): + AppointmentNote.objects.all().delete() UserAssignment.objects.all().delete() Symptom.objects.all().delete() BreastAugmentationHistoryItem.objects.all().delete() diff --git a/manage_breast_screening/participants/migrations/0049_appointmentnote.py b/manage_breast_screening/participants/migrations/0049_appointmentnote.py new file mode 100644 index 000000000..0f0067a40 --- /dev/null +++ b/manage_breast_screening/participants/migrations/0049_appointmentnote.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.8 on 2025-12-02 14:21 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('participants', '0048_alter_symptom_appointment'), + ] + + operations = [ + migrations.CreateModel( + name='AppointmentNote', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('content', models.TextField(blank=True)), + ('appointment', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='note', to='participants.appointment')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/manage_breast_screening/participants/migrations/max_migration.txt b/manage_breast_screening/participants/migrations/max_migration.txt index a7ef713a7..02a8e89a7 100644 --- a/manage_breast_screening/participants/migrations/max_migration.txt +++ b/manage_breast_screening/participants/migrations/max_migration.txt @@ -1 +1 @@ -0048_alter_symptom_appointment +0049_appointmentnote diff --git a/manage_breast_screening/participants/models/__init__.py b/manage_breast_screening/participants/models/__init__.py index 415b0e3db..060a9bc55 100644 --- a/manage_breast_screening/participants/models/__init__.py +++ b/manage_breast_screening/participants/models/__init__.py @@ -1,4 +1,4 @@ -from .appointment import Appointment, AppointmentStatus +from .appointment import Appointment, AppointmentNote, AppointmentStatus from .benign_lump_history_item import BenignLumpHistoryItem from .breast_augmentation_history_item import BreastAugmentationHistoryItem from .breast_cancer_history_item import BreastCancerHistoryItem @@ -14,6 +14,7 @@ __all__ = [ "Appointment", + "AppointmentNote", "AppointmentStatus", "BenignLumpHistoryItem", "BreastAugmentationHistoryItem", diff --git a/manage_breast_screening/participants/models/appointment.py b/manage_breast_screening/participants/models/appointment.py index e5b32bafa..ac8bbd234 100644 --- a/manage_breast_screening/participants/models/appointment.py +++ b/manage_breast_screening/participants/models/appointment.py @@ -195,3 +195,15 @@ def is_in_progress(self): def __str__(self): return self.state + + +class AppointmentNote(BaseModel): + appointment = models.OneToOneField( + "participants.Appointment", + on_delete=models.PROTECT, + related_name="note", + ) + content = models.TextField(blank=True) + + def __str__(self): + return f"Note for appointment {self.appointment_id}" diff --git a/manage_breast_screening/participants/tests/factories.py b/manage_breast_screening/participants/tests/factories.py index 192836629..72faff128 100644 --- a/manage_breast_screening/participants/tests/factories.py +++ b/manage_breast_screening/participants/tests/factories.py @@ -161,6 +161,14 @@ def current_status(obj, create, extracted, **kwargs): ) +class AppointmentNoteFactory(DjangoModelFactory): + class Meta: + model = models.AppointmentNote + + appointment = SubFactory(AppointmentFactory) + content = Faker("sentence") + + class BreastCancerHistoryItemFactory(DjangoModelFactory): class Meta: model = models.BreastCancerHistoryItem diff --git a/manage_breast_screening/tests/system/clinical/test_appointment_note.py b/manage_breast_screening/tests/system/clinical/test_appointment_note.py new file mode 100644 index 000000000..feb3d3314 --- /dev/null +++ b/manage_breast_screening/tests/system/clinical/test_appointment_note.py @@ -0,0 +1,63 @@ +from django.urls import reverse +from playwright.sync_api import expect + +from manage_breast_screening.participants.tests.factories import AppointmentFactory + +from ..system_test_setup import SystemTestCase + + +class TestAppointmentNote(SystemTestCase): + def test_clinical_user_adds_and_updates_an_appointment_note(self): + self.initial_note_text = "Participant prefers an evening appointment." + self.updated_note_text = "Participant prefers morning appointments only." + + self.given_i_am_logged_in_as_a_clinical_user() + self.and_there_is_an_appointment_for_my_provider() + self.and_i_am_on_the_appointment_note_page() + + self.when_i_save_the_note() + self.then_i_see_a_validation_error() + + self.when_i_enter_a_note() + self.and_i_save_the_note() + self.then_the_note_field_contains(self.initial_note_text) + + self.when_i_update_the_note() + self.and_i_save_the_note() + self.then_the_note_field_contains(self.updated_note_text) + + def and_there_is_an_appointment_for_my_provider(self): + self.appointment = AppointmentFactory( + clinic_slot__clinic__setting__provider=self.current_provider + ) + + def and_i_am_on_the_appointment_note_page(self): + self.page.goto( + self.live_server_url + + reverse("mammograms:appointment_note", kwargs={"pk": self.appointment.pk}) + ) + self.expect_url("mammograms:appointment_note", pk=self.appointment.pk) + + def then_i_see_a_validation_error(self): + self.expect_validation_error( + error_text="Enter a note", + field_label="Note", + ) + + def when_i_enter_a_note(self): + self.page.get_by_label("Note").fill(self.initial_note_text) + + def when_i_update_the_note(self): + field = self.page.get_by_label("Note") + expect(field).to_have_value(self.initial_note_text) + field.fill(self.updated_note_text) + + def and_i_save_the_note(self): + self.page.get_by_role("button", name="Save note").click() + self.expect_url("mammograms:appointment_note", pk=self.appointment.pk) + + def when_i_save_the_note(self): + self.and_i_save_the_note() + + def then_the_note_field_contains(self, text): + expect(self.page.get_by_label("Note")).to_have_value(text) diff --git a/manage_breast_screening/tests/system/clinical/test_user_submits_cannot_go_ahead_form.py b/manage_breast_screening/tests/system/clinical/test_cannot_go_ahead_form.py similarity index 100% rename from manage_breast_screening/tests/system/clinical/test_user_submits_cannot_go_ahead_form.py rename to manage_breast_screening/tests/system/clinical/test_cannot_go_ahead_form.py diff --git a/manage_breast_screening/tests/system/general/test_user_views_clinic_show_page.py b/manage_breast_screening/tests/system/clinical/test_clinic_show_page.py similarity index 100% rename from manage_breast_screening/tests/system/general/test_user_views_clinic_show_page.py rename to manage_breast_screening/tests/system/clinical/test_clinic_show_page.py diff --git a/manage_breast_screening/tests/system/general/test_viewing_a_completed_appointment.py b/manage_breast_screening/tests/system/clinical/test_viewing_a_completed_appointment.py similarity index 100% rename from manage_breast_screening/tests/system/general/test_viewing_a_completed_appointment.py rename to manage_breast_screening/tests/system/clinical/test_viewing_a_completed_appointment.py diff --git a/manage_breast_screening/tests/system/test_cis2_back_channel_logout.py b/manage_breast_screening/tests/system/general/test_cis2_back_channel_logout.py similarity index 99% rename from manage_breast_screening/tests/system/test_cis2_back_channel_logout.py rename to manage_breast_screening/tests/system/general/test_cis2_back_channel_logout.py index 25393ca6b..48b6613dd 100644 --- a/manage_breast_screening/tests/system/test_cis2_back_channel_logout.py +++ b/manage_breast_screening/tests/system/general/test_cis2_back_channel_logout.py @@ -11,7 +11,7 @@ from manage_breast_screening.auth.oauth import oauth -from .system_test_setup import SystemTestCase +from ..system_test_setup import SystemTestCase class TestCIS2BackChannelLogout(SystemTestCase): diff --git a/manage_breast_screening/tests/system/test_login.py b/manage_breast_screening/tests/system/general/test_login.py similarity index 99% rename from manage_breast_screening/tests/system/test_login.py rename to manage_breast_screening/tests/system/general/test_login.py index 811d1a455..6475f9b87 100644 --- a/manage_breast_screening/tests/system/test_login.py +++ b/manage_breast_screening/tests/system/general/test_login.py @@ -15,7 +15,7 @@ UserAssignmentFactory, ) -from .system_test_setup import SystemTestCase +from ..system_test_setup import SystemTestCase class TestLogin(SystemTestCase): diff --git a/manage_breast_screening/tests/system/system_test_setup.py b/manage_breast_screening/tests/system/system_test_setup.py index 3db52386e..80947d48e 100644 --- a/manage_breast_screening/tests/system/system_test_setup.py +++ b/manage_breast_screening/tests/system/system_test_setup.py @@ -84,26 +84,32 @@ def login_as_role(self, role: Role): def expect_validation_error( self, error_text: str, - fieldset_legend: str, field_label: str, - field_name: str | None = "", + fieldset_legend: str | None = None, + field_name: str | None = None, ): summary_box = self.page.locator(".nhsuk-error-summary") expect(summary_box).to_contain_text(error_text) - error_link = summary_box.get_by_text(error_text) error_link.click() - fieldset = self.page.locator("fieldset").filter(has_text=fieldset_legend) - error_span = fieldset.locator("span").filter(has_text=error_text) - expect(error_span).to_contain_text(error_text) - - if field_name: - field = fieldset.get_by_label(field_label, exact=True).and_( - fieldset.locator(f"[name='{field_name}']") - ) + if fieldset_legend: + fieldset = self.page.locator("fieldset").filter(has_text=fieldset_legend) + error_span = fieldset.locator("span").filter(has_text=error_text) + expect(error_span).to_contain_text(error_text) + if field_name: + field = fieldset.get_by_label(field_label, exact=True).and_( + fieldset.locator(f"[name='{field_name}']") + ) + else: + field = fieldset.get_by_label(field_label, exact=True) else: - field = fieldset.get_by_label(field_label, exact=True) + # No fieldset specified, look for the field directly + field = self.page.get_by_label(field_label, exact=True) + field_container = field.locator("..") + error_span = field_container.locator(".nhsuk-error-message") + expect(error_span).to_be_visible() + expect(error_span).to_contain_text(error_text) expect(field).to_be_focused()