"""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.id}" 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(summary)}
Sent automatically by the PESCO NCR system.