+
+ {{ form_error_summary(form) }}
+
+
+
+ {{ 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
+ }) }}
+
+
+{% 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()