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
« 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."""
3import logging
4from urllib.parse import urlencode
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
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)
25logger = logging.getLogger(__name__)
28TERMINAL_TASK_STATUSES = {
29 TaskStatus.Completed,
30 TaskStatus.Failed,
31 TaskStatus.Cancelled,
32}
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
44 messaging.send_solve_completion_email_message(payload)
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
55 if task.parent_id:
56 return None
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
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
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
82 if not scenario.send_solve_complete_email:
83 return None
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
90 scheduled_count, successful_count, failed_count, cancelled_count = _summary_counts(
91 task,
92 is_multi_solve=is_multi_solve,
93 )
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 )
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 )
121 record = _claim_email_record(task, scenario, payload)
122 if record is None:
123 return
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
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)
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")
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
158 _mark_email_record(record, SolveCompletionEmailDeliveryStatus.SENT)
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."""
164 if scenario_id is not None:
165 return scenario_id
167 debug_data = task.debug or {}
168 resolved = debug_data.get("scenario_id")
169 if isinstance(resolved, int):
170 return resolved
172 return None
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()
180 return bool((task.debug or {}).get("idaes_dispatched"))
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."""
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)
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
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
205 if metadata.successful_tasks == metadata.scheduled_tasks:
206 return SolveCompletionEmailOutcomePayload.MULTI_ALL_SUCCEEDED
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
211 return SolveCompletionEmailOutcomePayload.MULTI_MIXED
214def _summary_counts(task: Task, *, is_multi_solve: bool) -> tuple[int, int, int, int]:
215 """Return summary counts used by templates and audit records."""
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 )
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)
229 return (
230 metadata.scheduled_tasks,
231 metadata.successful_tasks,
232 metadata.failed_tasks,
233 metadata.cancelled_tasks,
234 )
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."""
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
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."""
277 update_fields = ["delivery_status", "error", "updated_at"]
278 record.delivery_status = status
279 record.error = error
281 if status == SolveCompletionEmailDeliveryStatus.SENT:
282 record.sent_at = timezone.now()
283 update_fields.append("sent_at")
285 record.save(update_fields=update_fields)
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."""
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
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 }
325def _build_solve_url(payload: SolveCompletionEmailRequestPayload) -> str:
326 """Build the frontend deep link shown in solve completion emails."""
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}"
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]
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]
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."""
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]
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]