Skip to content

Commit b5c9e8f

Browse files
authored
Merge pull request #58 from NHSDigital/extract-presenters
Extract a presenter (view model) layer
2 parents 2ddd049 + ae8c843 commit b5c9e8f

21 files changed

Lines changed: 572 additions & 358 deletions

File tree

manage_breast_screening/clinics/models.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import date
2+
from enum import StrEnum
23

34
from django.db import models
45

@@ -20,7 +21,27 @@ class Setting(BaseModel):
2021
provider = models.ForeignKey(Provider, on_delete=models.CASCADE)
2122

2223

24+
class ClinicFilter(StrEnum):
25+
TODAY = "today"
26+
UPCOMING = "upcoming"
27+
COMPLETED = "completed"
28+
ALL = "all"
29+
30+
2331
class ClinicQuerySet(models.QuerySet):
32+
def by_filter(self, filter: str):
33+
match filter:
34+
case ClinicFilter.TODAY:
35+
return self.today()
36+
case ClinicFilter.UPCOMING:
37+
return self.upcoming()
38+
case ClinicFilter.COMPLETED:
39+
return self.completed()
40+
case ClinicFilter.ALL:
41+
return self
42+
case _:
43+
raise ValueError(filter)
44+
2445
def today(self):
2546
"""
2647
Clinics that start today
@@ -100,6 +121,15 @@ def session_type(self):
100121
def time_range(self):
101122
return {"start_time": self.starts_at, "end_time": self.ends_at}
102123

124+
@classmethod
125+
def filter_counts(cls):
126+
return {
127+
ClinicFilter.ALL: cls.objects.count(),
128+
ClinicFilter.TODAY: cls.objects.today().count(),
129+
ClinicFilter.UPCOMING: cls.objects.upcoming().count(),
130+
ClinicFilter.COMPLETED: cls.objects.completed().count(),
131+
}
132+
103133

104134
class ClinicSlot(BaseModel):
105135
clinic = models.ForeignKey(
@@ -164,4 +194,3 @@ class Status:
164194
)
165195
reinvite = models.BooleanField(default=False)
166196
stopped_reasons = models.JSONField(null=True, blank=True)
167-
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from ..utils.date_formatting import format_date, format_time_range
2+
from ..utils.string_formatting import sentence_case
3+
from .models import Clinic
4+
5+
6+
class ClinicsPresenter:
7+
def __init__(self, filtered_clinics, filter, counts_by_filter):
8+
self.clinics = [ClinicPresenter(clinic) for clinic in filtered_clinics]
9+
self.counts_by_filter = counts_by_filter
10+
self.filter = filter
11+
12+
@property
13+
def heading(self):
14+
if self.filter == "today":
15+
return "Today’s clinics"
16+
elif self.filter == "upcoming":
17+
return "Upcoming clinics"
18+
elif self.filter == "completed":
19+
return "Completed clinics this week"
20+
else:
21+
return "All clinics this week"
22+
23+
24+
class ClinicPresenter:
25+
STATUS_COLORS = {
26+
Clinic.State.SCHEDULED: "blue", # default blue
27+
Clinic.State.IN_PROGRESS: "blue",
28+
Clinic.State.CLOSED: "grey",
29+
}
30+
31+
def __init__(self, clinic):
32+
self._clinic = clinic
33+
self.starts_at = format_date(clinic.starts_at)
34+
self.session_type = clinic.session_type().capitalize()
35+
self.number_of_slots = clinic.clinic_slots.count()
36+
self.location_name = sentence_case(clinic.setting.name)
37+
self.time_range = format_time_range(clinic.time_range())
38+
self.type = clinic.get_type_display()
39+
self.risk_type = clinic.get_risk_type_display()
40+
41+
@property
42+
def state(self):
43+
return {
44+
"text": self._clinic.get_state_display(),
45+
"classes": "nhsuk-tag--" + self.STATUS_COLORS[self._clinic.state],
46+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from datetime import datetime
2+
from unittest.mock import MagicMock
3+
4+
import pytest
5+
6+
from manage_breast_screening.clinics.presenters import ClinicPresenter
7+
8+
from ..models import Clinic
9+
10+
11+
@pytest.fixture
12+
def mock_clinic():
13+
mock = MagicMock(spec=Clinic)
14+
15+
mock.starts_at = datetime(2025, 1, 1, 9)
16+
mock.session_type.return_value = "All day"
17+
mock.clinic_slots.count.return_value = 10
18+
mock.setting.name = "Test setting"
19+
mock.time_range.return_value = {
20+
"start_time": datetime(2025, 1, 1, 9),
21+
"end_time": datetime(2025, 1, 1, 15),
22+
}
23+
mock.get_type_display.return_value = "Screening"
24+
mock.get_risk_type_display.return_value = "Routine"
25+
26+
return mock
27+
28+
29+
def test_clinic_presenter(mock_clinic):
30+
presenter = ClinicPresenter(mock_clinic)
31+
32+
assert presenter.starts_at == "1 January 2025"
33+
assert presenter.session_type == "All day"
34+
assert presenter.number_of_slots == 10
35+
assert presenter.location_name == "Test setting"
36+
assert presenter.time_range == "9am to 3pm"
37+
assert presenter.type == "Screening"
38+
assert presenter.risk_type == "Routine"
Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from datetime import datetime
2-
31
from django.shortcuts import render
42

5-
from .models import Clinic, Provider, Setting
3+
from manage_breast_screening.clinics.presenters import ClinicsPresenter
4+
5+
from .models import Clinic
66

77
STATUS_COLORS = {
88
Clinic.State.SCHEDULED: "blue", # default blue
@@ -12,28 +12,12 @@
1212

1313

1414
def clinic_list(request, filter="today"):
15-
match filter:
16-
case "today":
17-
clinics = Clinic.objects.today()
18-
case "upcoming":
19-
clinics = Clinic.objects.upcoming()
20-
case "completed":
21-
clinics = Clinic.objects.completed()
22-
case _:
23-
clinics = Clinic.objects.all()
15+
clinics = Clinic.objects.prefetch_related("setting").by_filter(filter)
16+
counts_by_filter = Clinic.filter_counts()
17+
presenter = ClinicsPresenter(clinics, filter, counts_by_filter)
2418

2519
return render(
2620
request,
2721
"clinics/index.html",
28-
context={
29-
"filter": filter,
30-
"filteredClinics": clinics,
31-
"filteredClinicCounts": {
32-
"all": Clinic.objects.count(),
33-
"today": Clinic.objects.today().count(),
34-
"upcoming": Clinic.objects.upcoming().count(),
35-
"completed": Clinic.objects.completed().count(),
36-
},
37-
"STATUS_COLORS": STATUS_COLORS,
38-
},
22+
context={"presenter": presenter},
3923
)

manage_breast_screening/config/jinja2_env.py

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,6 @@
66
from jinja2 import ChoiceLoader, Environment, PackageLoader
77
from markupsafe import Markup, escape
88

9-
from ..utils.date_formatting import (
10-
format_date,
11-
format_date_time,
12-
format_relative_date,
13-
format_time,
14-
format_time_range,
15-
)
16-
179

1810
def no_wrap(value):
1911
"""
@@ -37,44 +29,6 @@ def as_hint(value):
3729
return Markup(f'<span class="app-text-grey">{value}</span>' if value else "")
3830

3931

40-
def sentence_case(value):
41-
"""
42-
Capitalise the first letter of a sentence.
43-
44-
>>> sentence_case('a quick brown fox jumps over the lazy dog')
45-
'A quick brown fox jumps over the lazy dog'
46-
47-
Unlike the built in `capitalize` filter, this will preserve
48-
capital letters already in the string:
49-
50-
>>> sentence_case('not in PACS')
51-
'Not in PACS'
52-
"""
53-
if not value:
54-
return ""
55-
56-
return value[0].upper() + value[1:]
57-
58-
59-
def format_nhs_number(value):
60-
"""
61-
Format an NHS number with spaces
62-
63-
>>> format_nhs_number('9998887777')
64-
'999 888 7777'
65-
"""
66-
if not value:
67-
return ""
68-
69-
digits = re.sub(r"\s", "", value)
70-
71-
return f"{digits[:3]} {digits[3:6]} {digits[6:]}"
72-
73-
74-
def format_age(value: int) -> str:
75-
return f"{value} years old"
76-
77-
7832
def environment(**options):
7933
env = Environment(**options, extensions=["jinja2.ext.do"])
8034
if env.loader:
@@ -92,14 +46,7 @@ def environment(**options):
9246
env.globals.update(
9347
{"static": static, "url": reverse, "STATIC_URL": settings.STATIC_URL}
9448
)
95-
env.filters["noWrap"] = no_wrap
96-
env.filters["asHint"] = as_hint
97-
env.filters["formatAge"] = format_age
98-
env.filters["formatDate"] = format_date
99-
env.filters["formatDateTime"] = format_date_time
100-
env.filters["formatNhsNumber"] = format_nhs_number
101-
env.filters["formatRelativeDate"] = format_relative_date
102-
env.filters["formatTimeRange"] = format_time_range
103-
env.filters["formatTimeString"] = format_time
104-
env.filters["sentenceCase"] = sentence_case
49+
env.filters["no_wrap"] = no_wrap
50+
env.filters["as_hint"] = as_hint
51+
10552
return env
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import re
2+
3+
from django.urls import reverse
4+
5+
from ..clinics.models import Appointment
6+
from ..utils.date_formatting import format_date, format_relative_date, format_time
7+
from ..utils.string_formatting import format_age, format_nhs_number, sentence_case
8+
9+
Status = Appointment.Status
10+
11+
12+
def status_colour(status):
13+
"""
14+
Color to render the status tag
15+
"""
16+
match status:
17+
case Status.CHECKED_IN:
18+
return "" # no colour will get solid dark blue
19+
case Status.SCREENED:
20+
return "green"
21+
case Status.DID_NOT_ATTEND | Status.CANCELLED:
22+
return "red"
23+
case Status.ATTENDED_NOT_SCREENED | Status.PARTIALLY_SCREENED:
24+
return "orange"
25+
case _:
26+
return "blue" # default blue
27+
28+
29+
def present_secondary_nav(id):
30+
"""
31+
Build a secondary nav for reviewing the information of screened/partially screened appointments.
32+
"""
33+
return [
34+
{
35+
"id": "all",
36+
"text": "Appointment details",
37+
"href": reverse("record_a_mammogram:start_screening", kwargs={"id": id}),
38+
"current": True,
39+
},
40+
{"id": "medical_information", "text": "Medical information", "href": "#"},
41+
{"id": "images", "text": "Images", "href": "#"},
42+
]
43+
44+
45+
class AppointmentPresenter:
46+
def __init__(self, appointment):
47+
self._appointment = appointment
48+
self._last_known_screening = appointment.screening_episode.previous()
49+
50+
self.allStatuses = Status
51+
self.id = appointment.id
52+
self.clinic_slot = ClinicSlotPresenter(appointment.clinic_slot)
53+
self.participant = ParticipantPresenter(
54+
appointment.screening_episode.participant
55+
)
56+
57+
@property
58+
def status(self):
59+
colour = status_colour(self._appointment.status)
60+
61+
return {
62+
"classes": f"nhsuk-tag--{colour} app-nowrap" if colour else "app-nowrap",
63+
"text": self._appointment.get_status_display(),
64+
"key": self._appointment.status,
65+
"is_confirmed": self._appointment.status == Status.CONFIRMED,
66+
}
67+
68+
@property
69+
def last_known_screening(self):
70+
return (
71+
{
72+
"date": format_date(self._last_known_screening.created_at),
73+
"relative_date": format_relative_date(
74+
self._last_known_screening.created_at
75+
),
76+
# TODO: the current model doesn't allow for knowing the type and location of a historical screening
77+
# if it is not tied to one of our clinic slots.
78+
"location": None,
79+
"type": None,
80+
}
81+
if self._last_known_screening
82+
else {}
83+
)
84+
85+
86+
class ClinicSlotPresenter:
87+
def __init__(self, clinic_slot):
88+
self._clinic_slot = clinic_slot
89+
self._clinic = clinic_slot.clinic
90+
91+
self.clinic_id = self._clinic.id
92+
93+
@property
94+
def clinic_type(self):
95+
return self._clinic.get_type_display().capitalize()
96+
97+
@property
98+
def slot_time_and_clinic_date(self):
99+
clinic_slot = self._clinic_slot
100+
clinic = self._clinic
101+
102+
return f"{format_time(clinic_slot.starts_at)} ({ clinic_slot.duration_in_minutes } minutes) - { format_date(clinic.starts_at) } ({ format_relative_date(clinic.starts_at) })"
103+
104+
105+
class ParticipantPresenter:
106+
def __init__(self, participant):
107+
self._participant = participant
108+
109+
self.extra_needs = participant.extra_needs
110+
self.ethnic_group = participant.ethnic_group
111+
self.full_name = participant.full_name
112+
self.nhs_number = format_nhs_number(participant.nhs_number)
113+
self.date_of_birth = format_date(participant.date_of_birth)
114+
self.age = format_age(participant.age())
115+
self.risk_level = sentence_case(participant.risk_level)
116+
117+
@property
118+
def ethnic_group_category(self):
119+
category = self._participant.ethnic_group_category()
120+
if category:
121+
return category.replace("Any other", "any other")
122+
else:
123+
return None
124+
125+
@property
126+
def address(self):
127+
address = self._participant.address
128+
if not address:
129+
return {}
130+
131+
return {"lines": address.lines, "postcode": address.postcode}

0 commit comments

Comments
 (0)