Skip to content

Commit acd28b1

Browse files
committed
Extract status into an append only log
This gives us a history of the changes a clinic has gone through. It does add a bit of friction with the ORM, because we can technically create Clinic objects without a state now, but we can encapsulate this in a service object.
1 parent 7e4b55b commit acd28b1

8 files changed

Lines changed: 171 additions & 33 deletions

File tree

manage_breast_screening/clinics/fixtures/clinics.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@
2828
"starts_at": "2025-04-01T09:00:00Z",
2929
"ends_at": "2025-04-04T17:00:00Z",
3030
"type": "SCREENING",
31-
"risk_type": "ROUTINE_RISK",
32-
"state": "IN_PROGRESS"
31+
"risk_type": "ROUTINE_RISK"
3332
}
3433
},
3534
{
@@ -86,5 +85,14 @@
8685
"starts_at": "2025-05-01T13:00:00Z",
8786
"duration_in_minutes": 30
8887
}
88+
},
89+
{
90+
"model": "clinics.clinicstatus",
91+
"pk": "82da38f1-f39f-4692-8a76-d621630caa69",
92+
"fields": {
93+
"created_at": "2025-06-20T09:22:36.070Z",
94+
"state": "IN_PROGRESS",
95+
"clinic": "ce662c8b-92dc-4c82-9d5d-1ddcb201e2b6"
96+
}
8997
}
9098
]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Generated by Django 5.2.3 on 2025-06-17 12:51
2+
3+
import uuid
4+
5+
import django.db.models.deletion
6+
from django.db import migrations, models
7+
8+
9+
def extract_status(apps, schema_editor):
10+
Clinic = apps.get_model("clinics", "Clinic")
11+
for clinic in Clinic.objects.all():
12+
clinic.statuses.create(state=clinic.state)
13+
14+
15+
def revert_extract_status(apps, schema_editor):
16+
Clinic = apps.get_model("clinics", "Clinic")
17+
for clinic in Clinic.objects.all():
18+
clinic.state = clinic.statuses.first().state
19+
clinic.save()
20+
21+
22+
class Migration(migrations.Migration):
23+
24+
dependencies = [
25+
("clinics", "0012_remove_screeningepisode_participant_and_more"),
26+
]
27+
28+
operations = [
29+
migrations.AlterField(
30+
model_name="clinic",
31+
name="state",
32+
field=models.CharField(
33+
choices=[
34+
("SCHEDULED", "Scheduled"),
35+
("IN_PROGRESS", "In progress"),
36+
("CLOSED", "Closed"),
37+
("CANCELLED", "Cancelled"),
38+
],
39+
max_length=50,
40+
null=True,
41+
),
42+
),
43+
migrations.CreateModel(
44+
name="ClinicStatus",
45+
fields=[
46+
(
47+
"id",
48+
models.UUIDField(
49+
default=uuid.uuid4,
50+
editable=False,
51+
primary_key=True,
52+
serialize=False,
53+
),
54+
),
55+
("created_at", models.DateTimeField(auto_now_add=True)),
56+
(
57+
"state",
58+
models.CharField(
59+
choices=[
60+
("SCHEDULED", "Scheduled"),
61+
("IN_PROGRESS", "In progress"),
62+
("CLOSED", "Closed"),
63+
("CANCELLED", "Cancelled"),
64+
],
65+
max_length=50,
66+
),
67+
),
68+
(
69+
"clinic",
70+
models.ForeignKey(
71+
on_delete=django.db.models.deletion.PROTECT,
72+
related_name="statuses",
73+
to="clinics.clinic",
74+
),
75+
),
76+
],
77+
options={
78+
"ordering": ["-created_at"],
79+
},
80+
),
81+
migrations.RunPython(extract_status, revert_extract_status),
82+
migrations.RemoveField(
83+
model_name="clinic",
84+
name="state",
85+
),
86+
]

manage_breast_screening/clinics/models.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from datetime import date
23
from enum import StrEnum
34

@@ -61,14 +62,11 @@ def completed(self):
6162
"""
6263
return self.filter(starts_at__date__lt=date.today())
6364

65+
def with_statuses(self):
66+
return self.prefetch_related("statuses")
6467

65-
class Clinic(BaseModel):
66-
class State:
67-
SCHEDULED = "SCHEDULED"
68-
IN_PROGRESS = "IN_PROGRESS"
69-
CLOSED = "CLOSED"
70-
CANCELLED = "CANCELLED"
7168

69+
class Clinic(BaseModel):
7270
class RiskType:
7371
MIXED_RISK = "MIXED_RISK"
7472
ROUTINE_RISK = "ROUTINE_RISK"
@@ -83,13 +81,6 @@ class TimeOfDay:
8381
MORNING = "morning"
8482
AFTERNOON = "afternoon"
8583

86-
STATE_CHOICES = {
87-
State.SCHEDULED: "Scheduled",
88-
State.IN_PROGRESS: "In progress",
89-
State.CLOSED: "Closed",
90-
State.CANCELLED: "Cancelled",
91-
}
92-
9384
RISK_TYPE_CHOICES = {
9485
RiskType.MIXED_RISK: "Mixed risk",
9586
RiskType.ROUTINE_RISK: "Routine risk",
@@ -103,10 +94,12 @@ class TimeOfDay:
10394
ends_at = models.DateTimeField()
10495
type = models.CharField(choices=TYPE_CHOICES, max_length=50)
10596
risk_type = models.CharField(choices=RISK_TYPE_CHOICES, max_length=50)
106-
state = models.CharField(choices=STATE_CHOICES, max_length=50)
10797

10898
objects = ClinicQuerySet.as_manager()
10999

100+
def current_status(self):
101+
return self.statuses.first()
102+
110103
def session_type(self):
111104
start_hour = self.starts_at.hour
112105
duration = (self.ends_at - self.starts_at).seconds
@@ -137,3 +130,27 @@ class ClinicSlot(BaseModel):
137130
)
138131
starts_at = models.DateTimeField()
139132
duration_in_minutes = models.IntegerField()
133+
134+
135+
class ClinicStatus(models.Model):
136+
SCHEDULED = "SCHEDULED"
137+
IN_PROGRESS = "IN_PROGRESS"
138+
CLOSED = "CLOSED"
139+
CANCELLED = "CANCELLED"
140+
141+
STATE_CHOICES = [
142+
(SCHEDULED, "Scheduled"),
143+
(IN_PROGRESS, "In progress"),
144+
(CLOSED, "Closed"),
145+
(CANCELLED, "Cancelled"),
146+
]
147+
148+
class Meta:
149+
ordering = ["-created_at"]
150+
151+
id = models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True)
152+
created_at = models.DateTimeField(auto_now_add=True)
153+
state = models.CharField(choices=STATE_CHOICES, max_length=50)
154+
clinic = models.ForeignKey(
155+
Clinic, on_delete=models.PROTECT, related_name="statuses"
156+
)

manage_breast_screening/clinics/presenters.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from ..core.utils.date_formatting import format_date, format_time_range
22
from ..core.utils.string_formatting import sentence_case
3-
from .models import Clinic
3+
from .models import ClinicStatus
44

55

66
class ClinicsPresenter:
@@ -23,9 +23,9 @@ def heading(self):
2323

2424
class ClinicPresenter:
2525
STATUS_COLORS = {
26-
Clinic.State.SCHEDULED: "blue", # default blue
27-
Clinic.State.IN_PROGRESS: "blue",
28-
Clinic.State.CLOSED: "grey",
26+
ClinicStatus.SCHEDULED: "blue", # default blue
27+
ClinicStatus.IN_PROGRESS: "blue",
28+
ClinicStatus.CLOSED: "grey",
2929
}
3030

3131
def __init__(self, clinic):
@@ -40,7 +40,11 @@ def __init__(self, clinic):
4040

4141
@property
4242
def state(self):
43+
status = self._clinic.current_status()
44+
value = status.state
45+
text = status.get_state_display()
46+
4347
return {
44-
"text": self._clinic.get_state_display(),
45-
"classes": "nhsuk-tag--" + self.STATUS_COLORS[self._clinic.state],
48+
"text": text,
49+
"classes": "nhsuk-tag--" + self.STATUS_COLORS[value],
4650
}

manage_breast_screening/clinics/tests/factories.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import datetime, timedelta, timezone
22

3-
from factory.declarations import LazyAttribute, Sequence, SubFactory
3+
from factory import post_generation
4+
from factory.declarations import LazyAttribute, RelatedFactoryList, Sequence, SubFactory
45
from factory.django import DjangoModelFactory
56
from factory.fuzzy import FuzzyChoice
67

@@ -24,20 +25,41 @@ class Meta:
2425
provider = SubFactory(ProviderFactory)
2526

2627

28+
class ClinicStatusFactory(DjangoModelFactory):
29+
class Meta:
30+
model = models.ClinicStatus
31+
32+
clinic = None
33+
state = models.ClinicStatus.SCHEDULED
34+
35+
2736
class ClinicFactory(DjangoModelFactory):
2837
class Meta:
2938
model = models.Clinic
3039
django_get_or_create = ("starts_at", "ends_at")
3140

3241
type = FuzzyChoice(models.Clinic.TYPE_CHOICES)
3342
risk_type = FuzzyChoice(models.Clinic.RISK_TYPE_CHOICES)
34-
state = models.Clinic.State.SCHEDULED
3543
starts_at = Sequence(
3644
lambda n: datetime(2025, 1, 1, 9, tzinfo=timezone.utc) + timedelta(hours=n)
3745
)
3846
ends_at = LazyAttribute(lambda o: o.starts_at + timedelta(minutes=30))
3947
setting = SubFactory(SettingFactory)
4048

49+
# Create a status by default
50+
statuses = RelatedFactoryList(
51+
ClinicStatusFactory, size=1, factory_related_name="clinic"
52+
)
53+
54+
# Allow passing an explicit status
55+
# e.g. `current_status=ClinicStatus.IN_PROGRESS`
56+
@post_generation
57+
def current_status(obj, create, extracted, **kwargs):
58+
if not create or not extracted:
59+
return
60+
61+
obj.statuses.add(ClinicStatusFactory.create(state=extracted, clinic=obj))
62+
4163

4264
class ClinicSlotFactory(DjangoModelFactory):
4365
class Meta:

manage_breast_screening/clinics/tests/test_models.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
from .factories import ClinicFactory
1111

1212

13-
def test_clinic_is_scheduled():
14-
factory = ClinicFactory.build()
15-
assert factory.state == models.Clinic.State.SCHEDULED
13+
@pytest.mark.django_db
14+
def test_clinic_current_status():
15+
clinic = ClinicFactory.create(current_status=models.ClinicStatus.SCHEDULED)
16+
assert clinic.current_status().state == models.ClinicStatus.SCHEDULED
1617

1718

1819
@pytest.mark.django_db

manage_breast_screening/clinics/tests/test_presenters.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from manage_breast_screening.clinics.presenters import ClinicPresenter
77

88
from ..models import Clinic
9+
from .factories import ClinicStatusFactory
910

1011

1112
@pytest.fixture
@@ -22,6 +23,7 @@ def mock_clinic():
2223
}
2324
mock.get_type_display.return_value = "Screening"
2425
mock.get_risk_type_display.return_value = "Routine"
26+
mock.current_status.return_value = ClinicStatusFactory.build()
2527

2628
return mock
2729

@@ -36,3 +38,7 @@ def test_clinic_presenter(mock_clinic):
3638
assert presenter.time_range == "9am to 3pm"
3739
assert presenter.type == "Screening"
3840
assert presenter.risk_type == "Routine"
41+
assert presenter.state == {
42+
"classes": "nhsuk-tag--blue",
43+
"text": "Scheduled",
44+
}

manage_breast_screening/clinics/views.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,6 @@
44

55
from .models import Clinic
66

7-
STATUS_COLORS = {
8-
Clinic.State.SCHEDULED: "blue", # default blue
9-
Clinic.State.IN_PROGRESS: "blue",
10-
Clinic.State.CLOSED: "grey",
11-
}
12-
137

148
def clinic_list(request, filter="today"):
159
clinics = Clinic.objects.prefetch_related("setting").by_filter(filter)

0 commit comments

Comments
 (0)