Skip to content

Commit a47f64f

Browse files
authored
Merge pull request #133 from NHSDigital/dtoss-9252-extract-status
[DTOSS-9252] Extract status into an append only table
2 parents 9bfbe58 + 4ce940b commit a47f64f

11 files changed

Lines changed: 191 additions & 42 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Database migrations are handled by [Django's database migration functionality](h
7878
- `poetry run manage.py migrate` loads database migrations
7979
- `poetry run manage.py makemigrations` generates new database migrations
8080

81-
Note the database migration runs in the deployment pipeline *after* the application deployment. The deployed code must be compatible with the schema before AND after the schema changes. This also removes potential errors when using a rolling app deployment as multiple app versions may access the database at the same time. To enforce it, make sure to always separate code changes and database migrations into different pull requests.
81+
Note the database migration runs in the deployment pipeline _after_ the application deployment. The deployed code must be compatible with the schema before AND after the schema changes. This also removes potential errors when using a rolling app deployment as multiple app versions may access the database at the same time. To enforce it, make sure to always separate code changes and database migrations into different pull requests.
8282

8383
### Django admin
8484

manage_breast_screening/assets/sass/main.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,4 @@ a[href="#"] {
4444
top: 10px;
4545
right: 0;
4646
text-align: right;
47-
}
47+
}

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+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 5.2.3 on 2025-06-20 11:13
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('clinics', '0013_alter_clinic_setting_alter_clinicslot_clinic_and_more'),
10+
('clinics', '0013_move_clinic_state_to_table'),
11+
]
12+
13+
operations = [
14+
]

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
@@ -2,8 +2,8 @@
22

33
from ..core.utils.date_formatting import format_date, format_time_range
44
from ..core.utils.string_formatting import sentence_case
5-
from .models import Clinic
65
from ..mammograms.presenters import AppointmentPresenter
6+
from .models import ClinicStatus
77

88

99
class ClinicsPresenter:
@@ -26,9 +26,9 @@ def heading(self):
2626

2727
class ClinicPresenter:
2828
STATUS_COLORS = {
29-
Clinic.State.SCHEDULED: "blue", # default blue
30-
Clinic.State.IN_PROGRESS: "blue",
31-
Clinic.State.CLOSED: "grey",
29+
ClinicStatus.SCHEDULED: "blue", # default blue
30+
ClinicStatus.IN_PROGRESS: "blue",
31+
ClinicStatus.CLOSED: "grey",
3232
}
3333

3434
def __init__(self, clinic):
@@ -44,9 +44,13 @@ def __init__(self, clinic):
4444

4545
@property
4646
def state(self):
47+
status = self._clinic.current_status()
48+
value = status.state
49+
text = status.get_state_display()
50+
4751
return {
48-
"text": self._clinic.get_state_display(),
49-
"classes": "nhsuk-tag--" + self.STATUS_COLORS[self._clinic.state],
52+
"text": text,
53+
"classes": "nhsuk-tag--" + self.STATUS_COLORS[value],
5054
}
5155

5256
@property

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: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1+
import uuid
12
from datetime import datetime
23
from unittest.mock import MagicMock
3-
import uuid
4-
import pytest
54

5+
import pytest
66
from django.urls import reverse
77

8-
from manage_breast_screening.clinics.presenters import (
9-
ClinicPresenter,
10-
AppointmentListPresenter,
11-
)
12-
from manage_breast_screening.clinics.models import Clinic
8+
from ..models import Clinic
9+
from ..presenters import AppointmentListPresenter, ClinicPresenter
10+
from .factories import ClinicStatusFactory
1311

1412

1513
@pytest.fixture
@@ -26,6 +24,7 @@ def mock_clinic():
2624
}
2725
mock.get_type_display.return_value = "Screening"
2826
mock.get_risk_type_display.return_value = "Routine"
27+
mock.current_status.return_value = ClinicStatusFactory.build()
2928

3029
return mock
3130

@@ -40,6 +39,10 @@ def test_clinic_presenter(mock_clinic):
4039
assert presenter.time_range == "9am to 3pm"
4140
assert presenter.type == "Screening"
4241
assert presenter.risk_type == "Routine"
42+
assert presenter.state == {
43+
"classes": "nhsuk-tag--blue",
44+
"text": "Scheduled",
45+
}
4346

4447

4548
class TestAppointmentListPresenter:

0 commit comments

Comments
 (0)