Skip to content

Commit 79ef273

Browse files
authored
Merge pull request #198 from NHSDigital/schedule-appointments
Schedule appointments
2 parents fd2a59e + 1826cd3 commit 79ef273

8 files changed

Lines changed: 488 additions & 1 deletion

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import os
2+
import time
3+
import uuid
4+
5+
import jwt
6+
import requests
7+
8+
from manage_breast_screening.notifications.models import Message, MessageBatch
9+
10+
EXPIRES_IN_MINUTES = 5
11+
12+
13+
class OAuthError(Exception):
14+
pass
15+
16+
17+
class ApiClient:
18+
def send_message_batch(self, message_batch: MessageBatch) -> requests.Response:
19+
response = requests.post(
20+
os.getenv("API_MESSAGE_BATCH_URL"),
21+
headers=self.headers(),
22+
json=self.message_batch_request_body(message_batch),
23+
timeout=10,
24+
)
25+
26+
return response
27+
28+
def message_batch_request_body(self, message_batch: MessageBatch) -> dict:
29+
return {
30+
"data": {
31+
"type": "MessageBatch",
32+
"attributes": {
33+
"routingPlanId": message_batch.routing_plan_id,
34+
"messageBatchReference": str(message_batch.id),
35+
"messages": [
36+
self.message_request_body(m)
37+
for m in message_batch.messages.all()
38+
],
39+
},
40+
}
41+
}
42+
43+
def message_request_body(self, message: Message) -> dict:
44+
return {
45+
"messageReference": str(message.id),
46+
"recipient": {"nhsNumber": message.appointment.nhs_number},
47+
}
48+
49+
def headers(self) -> dict:
50+
return {
51+
"content-type": "application/vnd.api+json",
52+
"accept": "application/vnd.api+json",
53+
"x-correlation-id": str(uuid.uuid4()),
54+
"authorization": f"Bearer {self.bearer_token()}",
55+
}
56+
57+
def bearer_token(self) -> str:
58+
auth_jwt = jwt.encode(
59+
{
60+
"sub": os.getenv("OAUTH_API_KEY"),
61+
"iss": os.getenv("OAUTH_API_KEY"),
62+
"jti": str(uuid.uuid4()),
63+
"aud": os.getenv("OAUTH_TOKEN_URL"),
64+
"exp": int(time.time()) + (EXPIRES_IN_MINUTES * 60),
65+
},
66+
os.getenv("PRIVATE_KEY"),
67+
"RS512",
68+
{"alg": "RS512", "typ": "JWT", "kid": os.getenv("OAUTH_API_KID")},
69+
)
70+
71+
response = requests.post(
72+
os.getenv("OAUTH_TOKEN_URL"),
73+
data={
74+
"grant_type": "client_credentials",
75+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
76+
"client_assertion": auth_jwt,
77+
},
78+
headers={"Content-Type": "application/x-www-form-urlencoded"},
79+
timeout=10,
80+
)
81+
82+
if response.status_code != 200:
83+
raise OAuthError(response.text)
84+
85+
return response.json()["access_token"]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from datetime import datetime, timedelta
2+
from zoneinfo import ZoneInfo
3+
4+
from django.core.management.base import BaseCommand, CommandError
5+
6+
from manage_breast_screening.notifications.api_client import ApiClient
7+
from manage_breast_screening.notifications.models import (
8+
Appointment,
9+
Message,
10+
MessageBatch,
11+
)
12+
13+
TZ_INFO = ZoneInfo("Europe/London")
14+
15+
16+
class Command(BaseCommand):
17+
"""
18+
Django Admin command which finds Appointment records which need batching up to send
19+
to Communication Management API, and creates MessageBatch and Message records for them.
20+
"""
21+
22+
def add_arguments(self, parser):
23+
parser.add_argument("routing_plan_id")
24+
25+
def handle(self, *args, **options):
26+
try:
27+
routing_plan_id = options["routing_plan_id"]
28+
29+
self.stdout.write("Finding appointments to include in batch...")
30+
appointments = Appointment.objects.filter(
31+
starts_at__lte=self.schedule_date(), message__isnull=True
32+
)
33+
34+
if not appointments:
35+
self.stdout.write("No appointments found to batch.")
36+
return
37+
38+
self.stdout.write(f"Found {appointments.count()} appointments to batch.")
39+
40+
message_batch = MessageBatch.objects.create(
41+
routing_plan_id=routing_plan_id,
42+
scheduled_at=datetime.today().replace(tzinfo=TZ_INFO),
43+
)
44+
45+
for appointment in appointments:
46+
Message.objects.create(appointment=appointment, batch=message_batch)
47+
48+
self.stdout.write(
49+
f"Created MessageBatch with ID {message_batch.id} containing {appointments.count()} messages."
50+
)
51+
52+
response = ApiClient().send_message_batch(message_batch)
53+
54+
if response.status_code == 201:
55+
self.mark_batch_as_sent(message_batch, response.json())
56+
self.stdout.write(f"{message_batch} sent")
57+
else:
58+
self.mark_batch_as_failed(message_batch)
59+
self.stdout.write(
60+
f"Failed to send batch. Status: {response.status_code}"
61+
)
62+
except Exception as e:
63+
raise CommandError(e)
64+
65+
def mark_batch_as_sent(self, message_batch: MessageBatch, response_json: dict):
66+
message_batch.notify_id = response_json["data"]["id"]
67+
message_batch.sent_at = datetime.now(tz=TZ_INFO)
68+
message_batch.status = "sent"
69+
message_batch.save()
70+
71+
for message_json in response_json["data"]["attributes"]["messages"]:
72+
message = Message.objects.get(pk=message_json["messageReference"])
73+
if message:
74+
message.notify_id = message_json["id"]
75+
message.status = "delivered"
76+
message.save()
77+
78+
def mark_batch_as_failed(self, message_batch: MessageBatch):
79+
message_batch.status = "failed"
80+
message_batch.save()
81+
82+
for message in message_batch.messages.all():
83+
message.status = "failed"
84+
message.save()
85+
86+
# TODO: Check the appointment notification rules here.
87+
def schedule_date(self) -> datetime:
88+
today = datetime.today()
89+
today_end = today.replace(
90+
hour=23, minute=59, second=59, microsecond=999999, tzinfo=TZ_INFO
91+
)
92+
return today_end + timedelta(weeks=4, days=4)

manage_breast_screening/notifications/tests/__init__.py

Whitespace-only changes.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import uuid
2+
from datetime import datetime, timedelta
3+
4+
from factory import Sequence, SubFactory, post_generation
5+
from factory.django import DjangoModelFactory
6+
7+
from .. import models
8+
9+
10+
class ClinicFactory(DjangoModelFactory):
11+
class Meta:
12+
model = models.Clinic
13+
14+
code = "BU001"
15+
name = "BREAST CARE UNIT"
16+
holding_clinic = False
17+
18+
19+
class AppointmentFactory(DjangoModelFactory):
20+
class Meta:
21+
model = models.Appointment
22+
23+
clinic = SubFactory(ClinicFactory)
24+
nhs_number = Sequence(lambda n: int("999%06d" % n))
25+
starts_at = datetime.now() + timedelta(weeks=4, days=4)
26+
27+
28+
class MessageFactory(DjangoModelFactory):
29+
class Meta:
30+
model = models.Message
31+
32+
appointment = SubFactory(AppointmentFactory)
33+
34+
35+
class MessageBatchFactory(DjangoModelFactory):
36+
class Meta:
37+
model = models.MessageBatch
38+
39+
routing_plan_id = str(uuid.uuid4())
40+
status = "unscheduled"
41+
42+
@post_generation
43+
def messages(self, create, extracted, **kwargs):
44+
if not create:
45+
return
46+
47+
if extracted:
48+
for message in extracted:
49+
self.messages.add(message)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import uuid
2+
from datetime import datetime, timedelta
3+
from unittest.mock import ANY, MagicMock, patch
4+
5+
import pytest
6+
import requests
7+
8+
from manage_breast_screening.notifications.api_client import ApiClient
9+
from manage_breast_screening.notifications.management.commands.send_message_batch import (
10+
TZ_INFO,
11+
Command,
12+
CommandError,
13+
)
14+
from manage_breast_screening.notifications.models import Message, MessageBatch
15+
from manage_breast_screening.notifications.tests.factories import AppointmentFactory
16+
17+
18+
@patch.object(
19+
ApiClient, "send_message_batch", return_value=MagicMock(spec=requests.Response)
20+
)
21+
class TestSendMessageBatch:
22+
@pytest.fixture
23+
def routing_plan_id(self):
24+
return str(uuid.uuid4())
25+
26+
@pytest.mark.django_db
27+
def test_handle_with_a_batch_to_send(
28+
self, mock_send_message_batch, routing_plan_id
29+
):
30+
"""Test sending message batch with valid Appointment data"""
31+
mock_send_message_batch.return_value.status_code = 201
32+
33+
appointment = AppointmentFactory(
34+
starts_at=datetime.today().replace(tzinfo=TZ_INFO)
35+
)
36+
37+
subject = Command()
38+
mock_mark_batch_as_sent = MagicMock()
39+
subject.mark_batch_as_sent = mock_mark_batch_as_sent
40+
41+
subject.handle(**{"routing_plan_id": routing_plan_id})
42+
43+
message_batches = MessageBatch.objects.filter(routing_plan_id=routing_plan_id)
44+
assert message_batches.count() == 1
45+
assert (
46+
Message.objects.filter(
47+
appointment=appointment, batch=message_batches[0]
48+
).count()
49+
== 1
50+
)
51+
mock_mark_batch_as_sent.assert_called_once_with(message_batches[0], ANY)
52+
53+
@pytest.mark.django_db
54+
def test_handle_with_nothing_to_send(
55+
self, mock_send_message_batch, routing_plan_id
56+
):
57+
"""Test that no MessageBatch or Message records are created when no appointments need notifications"""
58+
Command().handle(**{"routing_plan_id": routing_plan_id})
59+
60+
assert MessageBatch.objects.count() == 0
61+
assert Message.objects.count() == 0
62+
63+
@pytest.mark.django_db
64+
def test_handle_with_appointments_inside_schedule_window(
65+
self, mock_send_message_batch, routing_plan_id
66+
):
67+
"""Test that appointments with date inside the schedule period are notified"""
68+
mock_send_message_batch.return_value.status_code = 201
69+
appointment = AppointmentFactory(
70+
starts_at=datetime.now().replace(tzinfo=TZ_INFO)
71+
+ timedelta(weeks=4, days=4)
72+
)
73+
74+
subject = Command()
75+
mock_mark_batch_as_sent = MagicMock()
76+
subject.mark_batch_as_sent = mock_mark_batch_as_sent
77+
78+
subject.handle(**{"routing_plan_id": routing_plan_id})
79+
80+
message_batches = MessageBatch.objects.filter(routing_plan_id=routing_plan_id)
81+
assert message_batches.count() == 1
82+
assert (
83+
Message.objects.filter(
84+
appointment=appointment, batch=message_batches[0]
85+
).count()
86+
== 1
87+
)
88+
mock_mark_batch_as_sent.assert_called_once_with(message_batches[0], ANY)
89+
90+
@pytest.mark.django_db
91+
def test_handle_with_appointments_outside_schedule_window(
92+
self, mock_send_message_batch, routing_plan_id
93+
):
94+
"""Test that appointments with date inside the schedule period are notified"""
95+
AppointmentFactory(
96+
starts_at=datetime.now().replace(tzinfo=TZ_INFO)
97+
+ timedelta(weeks=4, days=5)
98+
)
99+
100+
subject = Command()
101+
mock_mark_batch_as_sent = MagicMock()
102+
subject.mark_batch_as_sent = mock_mark_batch_as_sent
103+
104+
subject.handle(**{"routing_plan_id": routing_plan_id})
105+
106+
assert MessageBatch.objects.count() == 0
107+
assert Message.objects.count() == 0
108+
mock_mark_batch_as_sent.assert_not_called
109+
110+
@pytest.mark.django_db
111+
def test_handle_with_failing_notifications(
112+
self, mock_send_message_batch, routing_plan_id
113+
):
114+
"""Test that message batches which fail to send are marked correctly"""
115+
mock_send_message_batch.return_value.status_code = 400
116+
appointment = AppointmentFactory(
117+
starts_at=datetime.now().replace(tzinfo=TZ_INFO)
118+
+ timedelta(weeks=4, days=4)
119+
)
120+
121+
Command().handle(**{"routing_plan_id": routing_plan_id})
122+
123+
message_batches = MessageBatch.objects.filter(routing_plan_id=routing_plan_id)
124+
assert message_batches.count() == 1
125+
assert message_batches[0].status == "failed"
126+
messages = Message.objects.filter(
127+
appointment=appointment, batch=message_batches[0]
128+
)
129+
assert messages.count() == 1
130+
assert messages[0].status == "failed"
131+
132+
def test_handle_with_error(self, mock_send_message_batch, routing_plan_id):
133+
"""Test that errors are caught and raised as CommandErrors"""
134+
with pytest.raises(CommandError):
135+
Command().handle()

0 commit comments

Comments
 (0)