"""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" CAPA_ASSIGNED = "capa_assigned" _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", NotifyEvent.CAPA_ASSIGNED: "Corrective action assigned to you", } 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.CAPA_ASSIGNED: return [ncr.corrective_action_owner.email] if ncr.corrective_action_owner else [] 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"{e(k)}" f"{e(v or '')}" for k, v in rows ) return f"""

{e(_EVENT_SUBJECT[event])}

{e(summary)}

{table}

Open {e(ncr.ncr_number)}

Sent automatically by the PESCO NCR system.

""" 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}" ]