Files
pesco-ncr/backend/app/services/notifications.py
ang3l12 c1cd1bc9df Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification
Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2:

- Root Cause field + 6M Root Cause Category lookup (Man/Machine/Method/
  Material/Measurement/Environment), separate from Deviation Detail/Category
- "Corrective Action Required?" Yes/No gate on every NCR with a required
  justification
- Corrective action plan with owner + due date; owner is notified by email
- Effectiveness verification (result, notes, server-stamped verifier/date)
  required before an NCR can close when corrective action is required —
  costing returns 409 listing the missing pieces
- Recurring-issue flag with bidirectional NCR-to-NCR links; prior NCRs show
  a warning when later NCRs reference them
- Dashboard metrics: % root cause completed, % CAPA verified effective,
  avg CAPA close time, overdue CAPA count, NCRs by root cause category
- CAPA section in the NCR detail UI, printable PDF, CSV export, and the
  vw_ncr_full Power BI view; admin list manager for root cause categories
- Migrations 0003 (schema + seeded 6M lookup) and 0004 (view refresh);
  demo seed data exercises every metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:49:08 -06:00

161 lines
6.4 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"
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"<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}"
]