Initial commit: PESCO NCR system

Complete Non-Conformance Report system replacing the PowerApps/SharePoint
prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend,
React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS,
group-gated), Microsoft Graph delegated Mail.Send notifications (OBO),
six-stage workflow state machine with server-side enforcement, atomic
NCR-YYYY-NNNN numbering, attachments with camera capture, immutable
field-level audit trail, admin reopen, reports + CSV export, WeasyPrint
PDF traveler, Power BI reporting views + read-only DB user, documented
VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose
deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ang3l12
2026-07-13 11:41:22 -06:00
commit dea316b113
111 changed files with 13817 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
"""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"<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}"
]