Skip to content

Commit bcd1969

Browse files
committed
Add provider selection option and persist requested provider
Summary: - Add provider_requested to notifications/jobs/history and all-time view - Accept provider in v2 payloads and CSV job metadata with validation/gating - Persist requested provider while keeping sent_by as actual provider - Route SMS/email via requested provider when valid; fail closed if unavailable - Support SMS/email stubs and letter stub selection (dvla-stub) - Add helper for provider validation/allowed set - Update schemas, job API, and Celery payloads - Add migration + update current alembic head - Expand tests for provider option, job encoding, and schemas Notes: - Provider option gated by PROVIDER_OPTION_ENABLED; invalid provider returns 400 - Letter stub selection also gated by LETTER_STUB_ENABLED - Existing routing unchanged when provider is omitted
1 parent ec237cc commit bcd1969

25 files changed

+732
-22
lines changed

app/__init__.py

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@
4141
from app.clients.email.aws_ses_stub import AwsSesStubClient
4242
from app.clients.letter.dvla import DVLAClient
4343
from app.clients.sms.firetext import FiretextClient
44+
from app.clients.sms.firetext_stub import FiretextStubClient
4445
from app.clients.sms.mmg import MMGClient
46+
from app.clients.sms.mmg_stub import MMGStubClient
4547
from app.session import BindForcingSession
4648

4749
Base = declarative_base()
@@ -90,6 +92,32 @@
9092
memo_resetters.append(lambda: get_mmg_client.clear())
9193
mmg_client = LocalProxy(get_mmg_client)
9294

95+
_firetext_stub_client_context_var: ContextVar[FiretextStubClient] = ContextVar("firetext_stub_client")
96+
get_firetext_stub_client: LazyLocalGetter[FiretextStubClient] = LazyLocalGetter(
97+
_firetext_stub_client_context_var,
98+
lambda: FiretextStubClient(
99+
current_app,
100+
statsd_client=statsd_client,
101+
stub_url=current_app.config["FIRETEXT_STUB_URL"],
102+
),
103+
expected_type=FiretextStubClient,
104+
)
105+
memo_resetters.append(lambda: get_firetext_stub_client.clear())
106+
firetext_stub_client = LocalProxy(get_firetext_stub_client)
107+
108+
_mmg_stub_client_context_var: ContextVar[MMGStubClient] = ContextVar("mmg_stub_client")
109+
get_mmg_stub_client: LazyLocalGetter[MMGStubClient] = LazyLocalGetter(
110+
_mmg_stub_client_context_var,
111+
lambda: MMGStubClient(
112+
current_app,
113+
statsd_client=statsd_client,
114+
stub_url=current_app.config["MMG_STUB_URL"],
115+
),
116+
expected_type=MMGStubClient,
117+
)
118+
memo_resetters.append(lambda: get_mmg_stub_client.clear())
119+
mmg_stub_client = LocalProxy(get_mmg_stub_client)
120+
93121
_aws_ses_client_context_var: ContextVar[AwsSesClient] = ContextVar("aws_ses_client")
94122
get_aws_ses_client: LazyLocalGetter[AwsSesClient] = LazyLocalGetter(
95123
_aws_ses_client_context_var,
@@ -119,18 +147,19 @@
119147
_notification_provider_clients_context_var,
120148
lambda: NotificationProviderClients(
121149
sms_clients={
122-
getter.expected_type.name: LocalProxy(getter)
123-
for getter in (
124-
get_firetext_client,
125-
get_mmg_client,
126-
)
127-
},
150+
"firetext": LocalProxy(get_firetext_client),
151+
"mmg": LocalProxy(get_mmg_client),
152+
}
153+
| (
154+
{"firetext-stub": LocalProxy(get_firetext_stub_client)}
155+
if current_app.config.get("FIRETEXT_STUB_URL")
156+
else {}
157+
)
158+
| ({"mmg-stub": LocalProxy(get_mmg_stub_client)} if current_app.config.get("MMG_STUB_URL") else {}),
128159
email_clients={
129-
getter.expected_type.name: LocalProxy(getter)
130-
# If a stub url is provided for SES, then use the stub client rather
131-
# than the real SES boto client
132-
for getter in ((get_aws_ses_stub_client,) if current_app.config["SES_STUB_URL"] else (get_aws_ses_client,))
133-
},
160+
"ses": LocalProxy(get_aws_ses_client),
161+
}
162+
| ({"ses-stub": LocalProxy(get_aws_ses_stub_client)} if current_app.config.get("SES_STUB_URL") else {}),
134163
),
135164
)
136165
memo_resetters.append(lambda: get_notification_provider_clients.clear())

app/celery/provider_tasks.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
from app.delivery import send_to_providers
3333
from app.exceptions import NotificationTechnicalFailureException
3434
from app.letters.utils import LetterPDFNotFound, find_letter_pdf_in_s3
35+
from app.models import Notification
36+
from app.provider_selection import get_allowed_providers
3537

3638

3739
@notify_celery.task(
@@ -142,6 +144,9 @@ def deliver_letter(self, notification_id):
142144
)
143145
return
144146

147+
if _handle_requested_letter_provider(notification):
148+
return
149+
145150
try:
146151
file_bytes = find_letter_pdf_in_s3(notification).get()["Body"].read()
147152
except (BotoClientError, LetterPDFNotFound) as e:
@@ -194,16 +199,48 @@ def deliver_letter(self, notification_id):
194199
raise NotificationTechnicalFailureException(f"Error when sending letter notification {notification_id}") from e
195200

196201

197-
def update_letter_to_sending(notification):
198-
provider = get_provider_details_by_notification_type(LETTER_TYPE)[0]
202+
def update_letter_to_sending(notification, provider_identifier=None):
203+
if provider_identifier is None:
204+
provider = get_provider_details_by_notification_type(LETTER_TYPE)[0]
205+
provider_identifier = provider.identifier
199206

200207
notification.status = NOTIFICATION_SENDING
201208
notification.sent_at = datetime.utcnow()
202-
notification.sent_by = provider.identifier
209+
notification.sent_by = provider_identifier
203210

204211
notifications_dao.dao_update_notification(notification)
205212

206213

214+
def _handle_requested_letter_provider(notification: Notification) -> bool:
215+
"""Handle explicit provider routing for letters.
216+
217+
Returns True if the notification was handled (e.g., dvla-stub), False otherwise.
218+
"""
219+
if not notification.provider_requested:
220+
return False
221+
222+
allowed = get_allowed_providers(LETTER_TYPE)
223+
if notification.provider_requested not in allowed:
224+
current_app.logger.error(
225+
"Requested provider %s is not available for letter notifications",
226+
notification.provider_requested,
227+
extra={
228+
"notification_id": notification.id,
229+
"provider_requested": notification.provider_requested,
230+
},
231+
)
232+
update_notification_status_by_id(notification.id, NOTIFICATION_TECHNICAL_FAILURE)
233+
raise NotificationTechnicalFailureException(
234+
f"Requested provider {notification.provider_requested} is not available for letter notifications"
235+
)
236+
237+
if notification.provider_requested == "dvla-stub":
238+
update_letter_to_sending(notification, provider_identifier="dvla-stub")
239+
return True
240+
241+
return False
242+
243+
207244
def _get_callback_url(notification_id: UUID) -> str:
208245
signed_notification_id = signing.encode(str(notification_id))
209246

app/celery/tasks.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ def get_id_task_args_kwargs_for_job_row(row, template, job, service, sender_id=N
225225
"personalisation": dict(row.personalisation),
226226
# row.recipient_and_personalisation gets all columns for the row, even those not in template placeholders
227227
"client_reference": dict(row.recipient_and_personalisation).get("reference", None),
228+
"provider_requested": job.provider_requested,
228229
}
229230
)
230231

@@ -346,6 +347,7 @@ def save_sms(
346347
notification_id=notification_id,
347348
reply_to_text=reply_to_text,
348349
client_reference=notification.get("client_reference", None),
350+
provider_requested=notification.get("provider_requested"),
349351
**extra_args,
350352
)
351353

@@ -432,6 +434,7 @@ def save_email(self, service_id, notification_id, encoded_notification, sender_i
432434
notification_id=notification_id,
433435
reply_to_text=reply_to_text,
434436
client_reference=notification.get("client_reference", None),
437+
provider_requested=notification.get("provider_requested"),
435438
)
436439

437440
provider_tasks.deliver_email.apply_async(
@@ -491,6 +494,7 @@ def save_letter(
491494
client_reference=notification.get("client_reference", None),
492495
reply_to_text=template.reply_to_text,
493496
status=NOTIFICATION_CREATED,
497+
provider_requested=notification.get("provider_requested"),
494498
)
495499

496500
letters_pdf_tasks.get_pdf_for_templated_letter.apply_async(

app/clients/email/aws_ses_stub.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class AwsSesStubClient(EmailClient):
1818
This class is not thread-safe.
1919
"""
2020

21-
name = "ses"
21+
name = "ses-stub"
2222

2323
def __init__(self, region, statsd_client, stub_url):
2424
super().__init__()

app/clients/sms/firetext_stub.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import json
2+
from time import monotonic
3+
4+
import requests
5+
from flask import current_app
6+
7+
from app.clients.sms import SmsClient, SmsClientResponseException
8+
9+
10+
class FiretextStubClientException(SmsClientResponseException):
11+
pass
12+
13+
14+
class FiretextStubClient(SmsClient):
15+
"""
16+
Firetext "stub" SMS client for sending SMS to a testing stub.
17+
18+
This class is not thread-safe.
19+
"""
20+
21+
name = "firetext-stub"
22+
23+
def __init__(self, current_app, statsd_client, stub_url):
24+
super().__init__(current_app, statsd_client)
25+
self.url = stub_url
26+
self.requests_session = requests.Session()
27+
28+
def try_send_sms(self, to, content, reference, international, sender):
29+
"""
30+
Send SMS to the Firetext stub endpoint.
31+
"""
32+
data = {
33+
"from": sender,
34+
"to": to,
35+
"message": content,
36+
"reference": reference,
37+
}
38+
39+
try:
40+
start_time = monotonic()
41+
response = self.requests_session.request("POST", self.url, data=data, timeout=60)
42+
response.raise_for_status()
43+
44+
try:
45+
response_json = json.loads(response.text)
46+
if response_json.get("code") != 0:
47+
raise ValueError("Expected 'code' to be '0'")
48+
except (ValueError, AttributeError, KeyError) as e:
49+
raise FiretextStubClientException("Invalid response JSON from stub") from e
50+
51+
except Exception as e:
52+
self.statsd_client.incr("clients.firetext_stub.error")
53+
raise FiretextStubClientException(str(e)) from e
54+
else:
55+
elapsed_time = monotonic() - start_time
56+
current_app.logger.info(
57+
"Firetext stub request finished in %.4g seconds", elapsed_time, {"duration": elapsed_time}
58+
)
59+
self.statsd_client.timing("clients.firetext_stub.request-time", elapsed_time)
60+
self.statsd_client.incr("clients.firetext_stub.success")

app/clients/sms/mmg_stub.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import json
2+
from time import monotonic
3+
4+
import requests
5+
from flask import current_app
6+
7+
from app.clients.sms import SmsClient, SmsClientResponseException
8+
9+
10+
class MMGStubClientException(SmsClientResponseException):
11+
pass
12+
13+
14+
class MMGStubClient(SmsClient):
15+
"""
16+
MMG "stub" SMS client for sending SMS to a testing stub.
17+
18+
This class is not thread-safe.
19+
"""
20+
21+
name = "mmg-stub"
22+
23+
def __init__(self, current_app, statsd_client, stub_url):
24+
super().__init__(current_app, statsd_client)
25+
self.url = stub_url
26+
self.requests_session = requests.Session()
27+
28+
def try_send_sms(self, to, content, reference, international, sender):
29+
"""
30+
Send SMS to the MMG stub endpoint.
31+
"""
32+
data = {
33+
"sender": sender,
34+
"to": to,
35+
"message": content,
36+
"reference": reference,
37+
}
38+
39+
try:
40+
start_time = monotonic()
41+
response = self.requests_session.request("POST", self.url, data=data, timeout=60)
42+
response.raise_for_status()
43+
44+
try:
45+
response_json = json.loads(response.text)
46+
if "reference" not in response_json:
47+
raise ValueError("Expected 'reference' in response")
48+
except (ValueError, AttributeError, KeyError) as e:
49+
raise MMGStubClientException("Invalid response JSON from stub") from e
50+
51+
except Exception as e:
52+
self.statsd_client.incr("clients.mmg_stub.error")
53+
raise MMGStubClientException(str(e)) from e
54+
else:
55+
elapsed_time = monotonic() - start_time
56+
current_app.logger.info(
57+
"MMG stub request finished in %.4g seconds", elapsed_time, {"duration": elapsed_time}
58+
)
59+
self.statsd_client.timing("clients.mmg_stub.request-time", elapsed_time)
60+
self.statsd_client.incr("clients.mmg_stub.success")

app/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,10 @@ class Config:
513513
MMG_URL = os.environ.get("MMG_URL", "https://api.mmg.co.uk/jsonv2a/api.php")
514514
FIRETEXT_URL = os.environ.get("FIRETEXT_URL", "https://www.firetext.co.uk/api/sendsms/json")
515515
SES_STUB_URL = os.environ.get("SES_STUB_URL")
516+
FIRETEXT_STUB_URL = os.environ.get("FIRETEXT_STUB_URL")
517+
MMG_STUB_URL = os.environ.get("MMG_STUB_URL")
518+
LETTER_STUB_ENABLED = os.environ.get("LETTER_STUB_ENABLED", "0") == "1"
519+
PROVIDER_OPTION_ENABLED = os.environ.get("PROVIDER_OPTION_ENABLED", "0") == "1"
516520

517521
DVLA_API_BASE_URL = os.environ.get("DVLA_API_BASE_URL", "https://uat.driver-vehicle-licensing.api.gov.uk")
518522
DVLA_API_TLS_CIPHERS = os.environ.get("DVLA_API_TLS_CIPHERS")

app/dao/notifications_dao.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"created_by_id",
8787
"postage",
8888
"document_download_count",
89+
"provider_requested",
8990
]
9091

9192

app/delivery/send_to_providers.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from app.exceptions import NotificationTechnicalFailureException
3636
from app.models import Notification
37+
from app.provider_selection import get_allowed_providers
3738
from app.serialised_models import SerialisedProviders, SerialisedService, SerialisedTemplate
3839

3940

@@ -45,7 +46,9 @@ def send_sms_to_provider(notification: Notification) -> None:
4546
return
4647

4748
if notification.status == "created":
48-
provider = provider_to_use(SMS_TYPE, notification.international)
49+
provider = provider_to_use(
50+
SMS_TYPE, notification.international, provider_requested=notification.provider_requested
51+
)
4952

5053
template_model = SerialisedTemplate.from_id_service_id_and_version(
5154
template_id=notification.template_id, service_id=service.id, version=notification.template_version
@@ -136,7 +139,7 @@ def send_email_to_provider(notification):
136139
technical_failure(notification=notification)
137140
return
138141
if notification.status == "created":
139-
provider = provider_to_use(EMAIL_TYPE)
142+
provider = provider_to_use(EMAIL_TYPE, provider_requested=notification.provider_requested)
140143

141144
template = SerialisedTemplate.from_id_service_id_and_version(
142145
template_id=notification.template_id, service_id=service.id, version=notification.template_version
@@ -197,7 +200,31 @@ def update_notification_to_sending(notification, provider):
197200
dao_update_notification(notification)
198201

199202

200-
def provider_to_use(notification_type, international=False):
203+
def provider_to_use(notification_type, international=False, provider_requested=None):
204+
# If a provider was explicitly requested, enforce availability and configuration.
205+
if provider_requested:
206+
allowed = get_allowed_providers(notification_type, international=international)
207+
if provider_requested not in allowed:
208+
current_app.logger.error(
209+
"Requested provider %s is not available for %s notifications",
210+
provider_requested,
211+
notification_type,
212+
extra={"notification_type": notification_type, "provider_requested": provider_requested},
213+
)
214+
raise Exception(f"Requested provider {provider_requested} is not available for {notification_type}")
215+
216+
provider = notification_provider_clients.get_client_by_name_and_type(provider_requested, notification_type)
217+
if not provider:
218+
current_app.logger.error(
219+
"Requested provider %s is not configured for %s notifications",
220+
provider_requested,
221+
notification_type,
222+
extra={"notification_type": notification_type, "provider_requested": provider_requested},
223+
)
224+
raise Exception(f"Requested provider {provider_requested} is not configured for {notification_type}")
225+
226+
return provider
227+
201228
active_providers = [
202229
p for p in SerialisedProviders.from_notification_type(notification_type, international) if p.active
203230
]

0 commit comments

Comments
 (0)