Files
pesco-ncr/backend/app/services/notifications.py
ang3l12 8d48f774bb Use NCR business number in URLs (/ncrs/NCR-2026-0021)
Detail routes, queue navigation, and notification email links now use the
business identifier instead of the database id — friendlier for end users
quoting NCR numbers. The API resolves both forms (case-insensitive number,
or legacy numeric id) so existing bookmarks and email links keep working.
Adds regression tests incl. a guard that /ncrs/export.csv isn't shadowed
by the path parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:15:16 -06:00

157 lines
6.2 KiB
Python

"""Stage-transition email notifications via Microsoft Graph delegated
Mail.Send. Mail is sent FROM the mailbox of the user whose action triggered
the transition (OBO flow — see services/graph.py).
Fault tolerance contract: a Graph/network failure must never block a workflow
transition. Every failure path logs and returns a human-readable warning that
the API surfaces in the response `warnings` array; the transition itself has
already been committed by the caller.
"""
import html
import logging
from enum import Enum
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser
from app.config import get_settings
from app.domain import STAGE_LABELS, STAGE_OWNER_ROLE, Role, Stage
from app.models import AppSetting, Ncr, User, UserRole
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
from app.services.graph import send_mail_as_user
logger = logging.getLogger(__name__)
class NotifyEvent(str, Enum):
CREATED = "created"
SECONDARY_ASSIGNED = "secondary_assigned"
RELEASED_TO_OPERATIONS = "released_to_operations"
OPERATIONS_COMPLETE = "operations_complete"
QC_CLOSED = "qc_closed"
CLOSED = "closed"
REOPENED = "reopened"
_EVENT_SUBJECT = {
NotifyEvent.CREATED: "New NCR submitted — disposition needed",
NotifyEvent.SECONDARY_ASSIGNED: "Secondary disposition review assigned to you",
NotifyEvent.RELEASED_TO_OPERATIONS: "NCR released to Operations",
NotifyEvent.OPERATIONS_COMPLETE: "Operations complete — QC inspection needed",
NotifyEvent.QC_CLOSED: "QC closed — costing needed",
NotifyEvent.CLOSED: "Your NCR has been closed",
NotifyEvent.REOPENED: "NCR reopened by an administrator",
}
async def notifications_enabled(db: AsyncSession) -> bool:
row = await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY)
if row is None:
return get_settings().notifications_enabled_default
return row.value == "true"
async def _role_emails(db: AsyncSession, role: Role) -> list[str]:
result = await db.execute(
select(User.email)
.join(UserRole, UserRole.user_id == User.id)
.where(UserRole.role == role.value, User.is_active.is_(True))
)
return [r[0] for r in result.all()]
async def _recipients(db: AsyncSession, ncr: Ncr, event: NotifyEvent) -> list[str]:
if event == NotifyEvent.CREATED:
return [ncr.disposition_authority.email]
if event == NotifyEvent.SECONDARY_ASSIGNED:
return [u.email for u in ncr.secondary_authorities]
if event == NotifyEvent.RELEASED_TO_OPERATIONS:
return await _role_emails(db, Role.OPERATIONS)
if event == NotifyEvent.OPERATIONS_COMPLETE:
return await _role_emails(db, Role.QC_INSPECTOR)
if event == NotifyEvent.QC_CLOSED:
return await _role_emails(db, Role.COSTING)
if event == NotifyEvent.CLOSED:
return [ncr.requester.email]
if event == NotifyEvent.REOPENED:
owner_role = STAGE_OWNER_ROLE.get(Stage(ncr.stage))
emails = await _role_emails(db, owner_role) if owner_role else []
if ncr.requester.email not in emails:
emails.append(ncr.requester.email)
return emails
return []
def _build_body(ncr: Ncr, event: NotifyEvent, summary: str) -> str:
e = html.escape
link = f"{get_settings().app_base_url}/ncrs/{ncr.ncr_number}"
rows = [
("NCR Number", ncr.ncr_number),
("Job Number", ncr.job_number),
("Department", ncr.department.name if ncr.department else ""),
("Deviation Category", ncr.deviation_category.name if ncr.deviation_category else ""),
("Current Stage", STAGE_LABELS.get(Stage(ncr.stage), ncr.stage)),
("Requester", ncr.requester.display_name if ncr.requester else ""),
]
table = "".join(
f"<tr><td style='padding:4px 12px 4px 0;color:#555'>{e(k)}</td>"
f"<td style='padding:4px 0'><strong>{e(v or '')}</strong></td></tr>"
for k, v in rows
)
return f"""
<div style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222">
<h2 style="margin:0 0 4px">{e(_EVENT_SUBJECT[event])}</h2>
<p style="margin:4px 0 12px">{e(summary)}</p>
<table style="border-collapse:collapse">{table}</table>
<p style="margin:16px 0">
<a href="{e(link)}" style="background:#1a5fb4;color:#fff;padding:10px 18px;
border-radius:4px;text-decoration:none">Open {e(ncr.ncr_number)}</a>
</p>
<p style="color:#888;font-size:12px">Sent automatically by the PESCO NCR system.</p>
</div>
"""
async def send_stage_notification(
db: AsyncSession,
ncr: Ncr,
event: NotifyEvent,
actor: CurrentUser,
summary: str,
) -> list[str]:
"""Best-effort notification. Returns a list of non-blocking warnings
(empty on success or when notifications are disabled)."""
try:
if not await notifications_enabled(db):
logger.info("Notifications disabled; skipping %s for %s", event, ncr.ncr_number)
return []
recipients = sorted(set(await _recipients(db, ncr, event)))
if not recipients:
logger.info("No recipients for %s on %s", event, ncr.ncr_number)
return []
if actor.token is None:
# dev auth mode: no real user token to send on behalf of
logger.info(
"[dev] Would send '%s' for %s from %s to %s",
event.value, ncr.ncr_number, actor.user.email, recipients,
)
return [
f"Email not sent (dev auth mode): '{_EVENT_SUBJECT[event]}' "
f"to {', '.join(recipients)}."
]
subject = f"[{ncr.ncr_number}] {_EVENT_SUBJECT[event]}"
body = _build_body(ncr, event, summary)
await send_mail_as_user(actor.token, subject, body, recipients)
logger.info(
"Sent %s notification for %s from %s to %s",
event.value, ncr.ncr_number, actor.user.email, recipients,
)
return []
except Exception as exc: # noqa: BLE001 — must never block the workflow
logger.exception("Notification failed for %s (%s)", ncr.ncr_number, event.value)
return [
f"The workflow change was saved, but the notification email could not "
f"be sent: {exc}"
]