Coverage for backend/django/core/auxiliary/services/solve_completion_email.py: 90%

139 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-05-13 02:47 +0000

1"""Best-effort solve-completion email queueing and delivery helpers.""" 

2 

3import logging 

4from urllib.parse import urlencode 

5 

6from django.conf import settings 

7from django.core.mail import EmailMultiAlternatives 

8from django.db import IntegrityError, transaction 

9from django.template.loader import render_to_string 

10from django.utils import timezone 

11 

12from common.models.solve_completion_email import ( 

13 SolveCompletionEmailOutcome as SolveCompletionEmailOutcomePayload, 

14 SolveCompletionEmailRequestPayload, 

15) 

16from common.services import messaging 

17from core.auxiliary.enums.generalEnums import TaskStatus 

18from core.auxiliary.models import Scenario, Task 

19from core.auxiliary.models.SolveCompletionEmail import ( 

20 SolveCompletionEmail, 

21 SolveCompletionEmailDeliveryStatus, 

22 SolveCompletionEmailOutcome, 

23) 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28TERMINAL_TASK_STATUSES = { 

29 TaskStatus.Completed, 

30 TaskStatus.Failed, 

31 TaskStatus.Cancelled, 

32} 

33 

34 

35def queue_solve_completion_email_for_task( 

36 task: Task, 

37 scenario_id: int | None = None, 

38) -> None: 

39 """Publish a solve completion email request when a terminal task qualifies.""" 

40 payload = build_solve_completion_email_request(task, scenario_id=scenario_id) 

41 if payload is None: 

42 return 

43 

44 messaging.send_solve_completion_email_message(payload) 

45 

46 

47def build_solve_completion_email_request( 

48 task: Task, 

49 scenario_id: int | None = None, 

50) -> SolveCompletionEmailRequestPayload | None: 

51 """Classify a terminal task and build the message payload for email delivery.""" 

52 if task.status not in TERMINAL_TASK_STATUSES: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true

53 return None 

54 

55 if task.parent_id: 

56 return None 

57 

58 if not _was_dispatched_to_idaes(task): 

59 logger.info( 

60 "Skipping solve completion email for task %s because it never reached the IDAES dispatch step.", 

61 task.id, 

62 ) 

63 return None 

64 

65 resolved_scenario_id = _resolve_scenario_id(task, scenario_id) 

66 if resolved_scenario_id is None: 

67 logger.info( 

68 "Skipping solve completion email for task %s because no scenario could be resolved.", 

69 task.id, 

70 ) 

71 return None 

72 

73 scenario = Scenario.objects.filter(id=resolved_scenario_id).first() 

74 if scenario is None: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true

75 logger.info( 

76 "Skipping solve completion email for task %s because scenario %s was not found.", 

77 task.id, 

78 resolved_scenario_id, 

79 ) 

80 return None 

81 

82 if not scenario.send_solve_complete_email: 

83 return None 

84 

85 is_multi_solve = bool(task.metadata_id) 

86 outcome_key = _classify_outcome(task, is_multi_solve=is_multi_solve) 

87 if outcome_key is None: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true

88 return None 

89 

90 scheduled_count, successful_count, failed_count, cancelled_count = _summary_counts( 

91 task, 

92 is_multi_solve=is_multi_solve, 

93 ) 

94 

95 return SolveCompletionEmailRequestPayload( 

96 task_id=task.id, 

97 parent_task_id=task.parent_id, 

98 flowsheet_id=task.flowsheet_id, 

99 scenario_id=scenario.id, 

100 recipient_user_id=task.creator_id, 

101 recipient_email=task.creator.email, 

102 task_status=task.status, 

103 is_multi_solve=is_multi_solve, 

104 outcome_key=outcome_key, 

105 scheduled_count=scheduled_count, 

106 successful_count=successful_count, 

107 failed_count=failed_count, 

108 cancelled_count=cancelled_count, 

109 ) 

110 

111 

112def deliver_solve_completion_email(payload: SolveCompletionEmailRequestPayload) -> None: 

113 """Render and send a solve completion email on a best-effort basis.""" 

114 task = Task.objects.select_related("flowsheet", "creator").get(id=payload.task_id) 

115 scenario = ( 

116 Scenario.objects.select_related("flowsheet").filter(id=payload.scenario_id).first() 

117 if payload.scenario_id is not None 

118 else None 

119 ) 

120 

121 record = _claim_email_record(task, scenario, payload) 

122 if record is None: 

123 return 

124 

125 if not payload.recipient_email: 

126 logger.info( 

127 "Skipping solve completion email for task %s because user %s has no email address.", 

128 payload.task_id, 

129 payload.recipient_user_id, 

130 ) 

131 _mark_email_record(record, SolveCompletionEmailDeliveryStatus.SKIPPED, error="Recipient has no email address.") 

132 return 

133 

134 template_stub = f"solve_completion_email/{payload.outcome_key.value}" 

135 context = _build_email_context(task, scenario, payload) 

136 subject = context["subject"] 

137 text_body = render_to_string(f"{template_stub}.txt", context) 

138 html_body = render_to_string(f"{template_stub}.html", context) 

139 

140 message = EmailMultiAlternatives( 

141 subject=subject, 

142 body=text_body, 

143 from_email=settings.DEFAULT_FROM_EMAIL, 

144 to=[payload.recipient_email], 

145 ) 

146 message.attach_alternative(html_body, "text/html") 

147 

148 try: 

149 message.send() 

150 except Exception as exc: 

151 logger.exception( 

152 "Failed to send solve completion email for task %s.", 

153 payload.task_id, 

154 ) 

155 _mark_email_record(record, SolveCompletionEmailDeliveryStatus.FAILED, error=str(exc)) 

156 return 

157 

158 _mark_email_record(record, SolveCompletionEmailDeliveryStatus.SENT) 

159 

160 

161def _resolve_scenario_id(task: Task, scenario_id: int | None) -> int | None: 

162 """Resolve the scenario id from the completion payload or stored task debug data.""" 

163 

164 if scenario_id is not None: 

165 return scenario_id 

166 

167 debug_data = task.debug or {} 

168 resolved = debug_data.get("scenario_id") 

169 if isinstance(resolved, int): 

170 return resolved 

171 

172 return None 

173 

174 

175def _was_dispatched_to_idaes(task: Task) -> bool: 

176 """Return whether a solve task, or any child in a multi-solve parent, reached IDAES dispatch.""" 

177 if task.metadata_id: 

178 return task.children.filter(debug__idaes_dispatched=True).exists() 

179 

180 return bool((task.debug or {}).get("idaes_dispatched")) 

181 

182 

183def _classify_outcome( 

184 task: Task, 

185 *, 

186 is_multi_solve: bool, 

187) -> SolveCompletionEmailOutcomePayload | None: 

188 """Map a terminal task and its roll-up state to a template outcome key.""" 

189 

190 if not is_multi_solve: 

191 single_map = { 

192 TaskStatus.Completed: SolveCompletionEmailOutcomePayload.SINGLE_SUCCESS, 

193 TaskStatus.Failed: SolveCompletionEmailOutcomePayload.SINGLE_FAILURE, 

194 TaskStatus.Cancelled: SolveCompletionEmailOutcomePayload.SINGLE_CANCELLED, 

195 } 

196 return single_map.get(task.status) 

197 

198 if task.status == TaskStatus.Cancelled: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 return SolveCompletionEmailOutcomePayload.MULTI_CANCELLED 

200 

201 metadata = task.metadata 

202 if metadata is None: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 return None 

204 

205 if metadata.successful_tasks == metadata.scheduled_tasks: 

206 return SolveCompletionEmailOutcomePayload.MULTI_ALL_SUCCEEDED 

207 

208 if metadata.failed_tasks == metadata.scheduled_tasks: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true

209 return SolveCompletionEmailOutcomePayload.MULTI_ALL_FAILED 

210 

211 return SolveCompletionEmailOutcomePayload.MULTI_MIXED 

212 

213 

214def _summary_counts(task: Task, *, is_multi_solve: bool) -> tuple[int, int, int, int]: 

215 """Return summary counts used by templates and audit records.""" 

216 

217 if not is_multi_solve: 

218 return ( 

219 1, 

220 int(task.status == TaskStatus.Completed), 

221 int(task.status == TaskStatus.Failed), 

222 int(task.status == TaskStatus.Cancelled), 

223 ) 

224 

225 metadata = task.metadata 

226 if metadata is None: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 return (0, 0, 0, 0) 

228 

229 return ( 

230 metadata.scheduled_tasks, 

231 metadata.successful_tasks, 

232 metadata.failed_tasks, 

233 metadata.cancelled_tasks, 

234 ) 

235 

236 

237def _claim_email_record( 

238 task: Task, 

239 scenario: Scenario | None, 

240 payload: SolveCompletionEmailRequestPayload, 

241) -> SolveCompletionEmail | None: 

242 """Create the delivery record that acts as the dedupe claim for this send.""" 

243 

244 try: 

245 with transaction.atomic(): 

246 return SolveCompletionEmail.objects.create( 

247 task=task, 

248 flowsheet_id=payload.flowsheet_id, 

249 scenario=scenario, 

250 recipient_id=payload.recipient_user_id, 

251 recipient_email=payload.recipient_email, 

252 terminal_status=payload.task_status, 

253 outcome_key=payload.outcome_key.value, 

254 is_multi_solve=payload.is_multi_solve, 

255 scheduled_count=payload.scheduled_count, 

256 successful_count=payload.successful_count, 

257 failed_count=payload.failed_count, 

258 cancelled_count=payload.cancelled_count, 

259 ) 

260 except IntegrityError: 

261 logger.info( 

262 "Skipping duplicate solve completion email attempt for task %s with status %s.", 

263 payload.task_id, 

264 payload.task_status, 

265 ) 

266 return None 

267 

268 

269def _mark_email_record( 

270 record: SolveCompletionEmail, 

271 status: SolveCompletionEmailDeliveryStatus, 

272 *, 

273 error: str | None = None, 

274) -> None: 

275 """Persist the final delivery state after send, skip, or failure.""" 

276 

277 update_fields = ["delivery_status", "error", "updated_at"] 

278 record.delivery_status = status 

279 record.error = error 

280 

281 if status == SolveCompletionEmailDeliveryStatus.SENT: 

282 record.sent_at = timezone.now() 

283 update_fields.append("sent_at") 

284 

285 record.save(update_fields=update_fields) 

286 

287 

288def _build_email_context( 

289 task: Task, 

290 scenario: Scenario | None, 

291 payload: SolveCompletionEmailRequestPayload, 

292) -> dict[str, object]: 

293 """Assemble the template context shared by both HTML and text emails.""" 

294 

295 scenario_name = scenario.displayName if scenario else "Scenario" 

296 flowsheet_name = task.flowsheet.name or f"Flowsheet {task.flowsheet_id}" 

297 solve_url = _build_solve_url(payload) 

298 outcome_label = _outcome_label(payload.outcome_key) 

299 outcome_icon, outcome_icon_color, outcome_icon_background = _outcome_icon_details( 

300 payload.outcome_key 

301 ) 

302 is_multi_solve = payload.is_multi_solve 

303 

304 return { 

305 "subject": f"Ahuora: {outcome_label} for {scenario_name}", 

306 "headline": _headline(payload.outcome_key), 

307 "body_copy": _body_copy(payload.outcome_key), 

308 "outcome_label": outcome_label, 

309 "outcome_icon": outcome_icon, 

310 "outcome_icon_color": outcome_icon_color, 

311 "outcome_icon_background": outcome_icon_background, 

312 "scenario_name": scenario_name, 

313 "flowsheet_name": flowsheet_name, 

314 "task_id": payload.task_id, 

315 "terminal_status": payload.task_status, 

316 "is_multi_solve": is_multi_solve, 

317 "scheduled_count": payload.scheduled_count, 

318 "successful_count": payload.successful_count, 

319 "failed_count": payload.failed_count, 

320 "cancelled_count": payload.cancelled_count, 

321 "solve_url": solve_url, 

322 } 

323 

324 

325def _build_solve_url(payload: SolveCompletionEmailRequestPayload) -> str: 

326 """Build the frontend deep link shown in solve completion emails.""" 

327 

328 base_url = settings.AHUORA_APP_BASE_URL.rstrip("/") 

329 query = urlencode( 

330 { 

331 "scenario": payload.scenario_id, 

332 "task": payload.task_id, 

333 } 

334 ) 

335 return f"{base_url}/project/{payload.flowsheet_id}/flowsheet?{query}" 

336 

337 

338def _outcome_label(outcome_key: SolveCompletionEmailOutcomePayload) -> str: 

339 labels = { 

340 SolveCompletionEmailOutcomePayload.SINGLE_SUCCESS: "Solve completed successfully", 

341 SolveCompletionEmailOutcomePayload.SINGLE_FAILURE: "Solve failed", 

342 SolveCompletionEmailOutcomePayload.SINGLE_CANCELLED: "Solve was cancelled", 

343 SolveCompletionEmailOutcomePayload.MULTI_ALL_SUCCEEDED: "Multi steady-state run completed successfully", 

344 SolveCompletionEmailOutcomePayload.MULTI_MIXED: "Multi steady-state run completed with mixed results", 

345 SolveCompletionEmailOutcomePayload.MULTI_ALL_FAILED: "Multi steady-state run failed", 

346 SolveCompletionEmailOutcomePayload.MULTI_CANCELLED: "Multi steady-state run was cancelled", 

347 } 

348 return labels[outcome_key] 

349 

350 

351def _headline(outcome_key: SolveCompletionEmailOutcomePayload) -> str: 

352 headlines = { 

353 SolveCompletionEmailOutcomePayload.SINGLE_SUCCESS: "Solve finished", 

354 SolveCompletionEmailOutcomePayload.SINGLE_FAILURE: "Solve failed", 

355 SolveCompletionEmailOutcomePayload.SINGLE_CANCELLED: "Solve cancelled", 

356 SolveCompletionEmailOutcomePayload.MULTI_ALL_SUCCEEDED: "Multi steady-state run finished", 

357 SolveCompletionEmailOutcomePayload.MULTI_MIXED: "Multi steady-state run finished with mixed results", 

358 SolveCompletionEmailOutcomePayload.MULTI_ALL_FAILED: "Multi steady-state run failed", 

359 SolveCompletionEmailOutcomePayload.MULTI_CANCELLED: "Multi steady-state run cancelled", 

360 } 

361 return headlines[outcome_key] 

362 

363 

364def _outcome_icon_details( 

365 outcome_key: SolveCompletionEmailOutcomePayload, 

366) -> tuple[str, str, str]: 

367 """Return the icon glyph and accent colors used by the shared email template.""" 

368 

369 details = { 

370 SolveCompletionEmailOutcomePayload.SINGLE_SUCCESS: ("✓", "#123524", "#dce9e2"), 

371 SolveCompletionEmailOutcomePayload.SINGLE_FAILURE: ("✕", "#8a2c1f", "#f7ddd8"), 

372 SolveCompletionEmailOutcomePayload.SINGLE_CANCELLED: ("⊘", "#7a4d11", "#f3e5cf"), 

373 SolveCompletionEmailOutcomePayload.MULTI_ALL_SUCCEEDED: ( 

374 "✓", 

375 "#123524", 

376 "#dce9e2", 

377 ), 

378 SolveCompletionEmailOutcomePayload.MULTI_MIXED: ("±", "#8a2c1f", "#f7ddd8"), 

379 SolveCompletionEmailOutcomePayload.MULTI_ALL_FAILED: ("✕", "#8a2c1f", "#f7ddd8"), 

380 SolveCompletionEmailOutcomePayload.MULTI_CANCELLED: ("⊘", "#7a4d11", "#f3e5cf"), 

381 } 

382 return details[outcome_key] 

383 

384 

385def _body_copy(outcome_key: SolveCompletionEmailOutcomePayload) -> str: 

386 copy = { 

387 SolveCompletionEmailOutcomePayload.SINGLE_SUCCESS: "Your solve reached a completed state and the latest results are ready to review in Ahuora.", 

388 SolveCompletionEmailOutcomePayload.SINGLE_FAILURE: "Your solve reached a failed state. Open Ahuora to review the task details and any solver diagnostics.", 

389 SolveCompletionEmailOutcomePayload.SINGLE_CANCELLED: "Your solve was cancelled before completion. Open Ahuora if you want to review the task history or restart it.", 

390 SolveCompletionEmailOutcomePayload.MULTI_ALL_SUCCEEDED: "All child solves in this multi steady-state run completed successfully.", 

391 SolveCompletionEmailOutcomePayload.MULTI_MIXED: "This multi steady-state run reached a terminal state with a mix of successful and failed child solves.", 

392 SolveCompletionEmailOutcomePayload.MULTI_ALL_FAILED: "Every child solve in this multi steady-state run failed.", 

393 SolveCompletionEmailOutcomePayload.MULTI_CANCELLED: "This multi steady-state run was cancelled before all child solves completed.", 

394 } 

395 return copy[outcome_key]