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:
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
67
backend/app/services/audit.py
Normal file
67
backend/app/services/audit.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Audit trail helpers. Audit rows are append-only: the application exposes
|
||||
no endpoint that updates or deletes them."""
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import AuditLog, Ncr
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def audit_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
action: str,
|
||||
ncr_id: int | None = None,
|
||||
field_name: str | None = None,
|
||||
old_value: Any = None,
|
||||
new_value: Any = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AuditLog(
|
||||
ncr_id=ncr_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
field_name=field_name,
|
||||
old_value=_fmt(old_value),
|
||||
new_value=_fmt(new_value),
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def apply_field_updates(
|
||||
db: AsyncSession,
|
||||
ncr: Ncr,
|
||||
user_id: int,
|
||||
updates: dict[str, Any],
|
||||
action: str = "update",
|
||||
) -> dict[str, tuple[Any, Any]]:
|
||||
"""Set attributes on the NCR, writing one audit row per actually-changed
|
||||
field. Returns {field: (old, new)} for the fields that changed."""
|
||||
changes: dict[str, tuple[Any, Any]] = {}
|
||||
for field, new_value in updates.items():
|
||||
old_value = getattr(ncr, field)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
setattr(ncr, field, new_value)
|
||||
changes[field] = (old_value, new_value)
|
||||
audit_event(
|
||||
db,
|
||||
ncr_id=ncr.id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
field_name=field,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
)
|
||||
return changes
|
||||
91
backend/app/services/graph.py
Normal file
91
backend/app/services/graph.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Microsoft Graph helpers.
|
||||
|
||||
Delegated access uses the OAuth2 On-Behalf-Of (OBO) flow: the SPA sends the
|
||||
API its access token (audience = this API); the API exchanges it with Entra ID
|
||||
for a Graph token carrying the *signed-in user's* identity, so mail goes out
|
||||
from that user's own mailbox. This keeps Graph scopes off the frontend and
|
||||
needs no extra token plumbing on state-changing requests.
|
||||
"""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
MAIL_SEND_SCOPE = "https://graph.microsoft.com/Mail.Send"
|
||||
GROUP_READ_SCOPE = "https://graph.microsoft.com/GroupMember.Read.All"
|
||||
|
||||
_cca = None
|
||||
|
||||
|
||||
def _get_cca():
|
||||
global _cca
|
||||
if _cca is None:
|
||||
import msal # imported lazily so tests never need Entra config
|
||||
|
||||
settings = get_settings()
|
||||
_cca = msal.ConfidentialClientApplication(
|
||||
settings.entra_client_id,
|
||||
authority=f"https://login.microsoftonline.com/{settings.entra_tenant_id}",
|
||||
client_credential=settings.entra_client_secret,
|
||||
)
|
||||
return _cca
|
||||
|
||||
|
||||
def _acquire_obo_sync(user_token: str, scopes: list[str]) -> str:
|
||||
result = _get_cca().acquire_token_on_behalf_of(
|
||||
user_assertion=user_token, scopes=scopes
|
||||
)
|
||||
if "access_token" in result:
|
||||
return result["access_token"]
|
||||
raise RuntimeError(
|
||||
f"OBO token exchange failed: {result.get('error')}: "
|
||||
f"{result.get('error_description')}"
|
||||
)
|
||||
|
||||
|
||||
async def acquire_obo_token(user_token: str, scopes: list[str]) -> str:
|
||||
"""Exchange the caller's API access token for a delegated Graph token."""
|
||||
return await anyio.to_thread.run_sync(partial(_acquire_obo_sync, user_token, scopes))
|
||||
|
||||
|
||||
async def send_mail_as_user(
|
||||
user_token: str, subject: str, html_body: str, to_emails: list[str]
|
||||
) -> None:
|
||||
"""Send an email from the signed-in user's mailbox (delegated Mail.Send)."""
|
||||
graph_token = await acquire_obo_token(user_token, [MAIL_SEND_SCOPE])
|
||||
payload = {
|
||||
"message": {
|
||||
"subject": subject,
|
||||
"body": {"contentType": "HTML", "content": html_body},
|
||||
"toRecipients": [{"emailAddress": {"address": e}} for e in to_emails],
|
||||
},
|
||||
"saveToSentItems": True,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.post(
|
||||
f"{GRAPH_BASE}/me/sendMail",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {graph_token}"},
|
||||
)
|
||||
if resp.status_code != 202:
|
||||
raise RuntimeError(f"Graph sendMail returned {resp.status_code}: {resp.text[:300]}")
|
||||
|
||||
|
||||
async def check_member_group(user_token: str, group_id: str) -> bool:
|
||||
"""Group-overage fallback: ask Graph whether the signed-in user is in the
|
||||
gate group. Requires delegated GroupMember.Read.All (see README)."""
|
||||
graph_token = await acquire_obo_token(user_token, [GROUP_READ_SCOPE])
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.post(
|
||||
f"{GRAPH_BASE}/me/checkMemberGroups",
|
||||
json={"groupIds": [group_id]},
|
||||
headers={"Authorization": f"Bearer {graph_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return group_id in resp.json().get("value", [])
|
||||
116
backend/app/services/job_lookup.py
Normal file
116
backend/app/services/job_lookup.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Job number lookup abstraction — the seam for the future Infor VISUAL ERP
|
||||
integration.
|
||||
|
||||
Today, Job Number is free text: the default NullJobLookupService accepts any
|
||||
value and returns no enrichment. When PESCO is ready to integrate VISUAL,
|
||||
implement VisualJobLookupService below, set JOB_LOOKUP_PROVIDER=visual (plus
|
||||
the VISUAL_DB_* variables) in .env, and restart — no schema or frontend
|
||||
changes required:
|
||||
|
||||
* the NCR schema already stores the job number exactly as entered, plus a
|
||||
related `job_info` row (part_id, part_description, customer_name,
|
||||
work_order_status) that any provider can populate at NCR creation;
|
||||
* the frontend job-number field already calls GET /api/jobs/{job_number}/lookup
|
||||
as the user types and displays whatever enrichment comes back, so
|
||||
validation/autocomplete light up automatically with a real provider.
|
||||
"""
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobInfoData:
|
||||
part_id: str | None = None
|
||||
part_description: str | None = None
|
||||
customer_name: str | None = None
|
||||
work_order_status: str | None = None
|
||||
source: str = "null"
|
||||
|
||||
|
||||
class JobLookupService(Protocol):
|
||||
async def lookup(self, job_number: str) -> JobInfoData | None:
|
||||
"""Return read-only enrichment for a job number, or None when the job
|
||||
is unknown / the provider has nothing to add. Implementations must
|
||||
never raise for a merely-unknown job number."""
|
||||
...
|
||||
|
||||
|
||||
class NullJobLookupService:
|
||||
"""Default provider: job numbers are accepted as-is, no enrichment."""
|
||||
|
||||
async def lookup(self, job_number: str) -> JobInfoData | None: # noqa: ARG002
|
||||
return None
|
||||
|
||||
|
||||
class VisualJobLookupService:
|
||||
"""PLACEHOLDER for the future Infor VISUAL Manufacturing (SQL Server)
|
||||
integration. Not implemented yet — selecting JOB_LOOKUP_PROVIDER=visual
|
||||
today raises at startup with a pointer here.
|
||||
|
||||
Implementation notes (verified against PESCO's VISUAL 10 schema):
|
||||
|
||||
* Connect read-only to the VISUAL SQL Server database (VISUAL_DB_* env
|
||||
vars) with a dedicated SELECT-only SQL login. Use `aioodbc` or `pymssql`.
|
||||
NEVER write to VISUAL tables — hundreds of triggers maintain derived
|
||||
values and direct writes bypass application validation.
|
||||
|
||||
* A PESCO "job number" corresponds to a work order base id (typically
|
||||
with lot/split/sub qualifiers). WORK_ORDER's primary key is composite:
|
||||
(TYPE, BASE_ID, LOT_ID, SPLIT_ID, SUB_ID); manufacturing work orders
|
||||
have TYPE = 'W'. Parse the entered job number into BASE_ID (and LOT_ID
|
||||
when the shop uses BASE/LOT notation, e.g. "12345/1") and query:
|
||||
|
||||
SELECT TOP 1 wo.BASE_ID, wo.LOT_ID, wo.SUB_ID, wo.PART_ID,
|
||||
wo.STATUS, wo.DESIRED_QTY, wo.CREATE_DATE,
|
||||
p.DESCRIPTION AS PART_DESCRIPTION
|
||||
FROM WORK_ORDER wo
|
||||
LEFT JOIN PART p ON p.ID = wo.PART_ID
|
||||
WHERE wo.TYPE = 'W' AND wo.BASE_ID = :base_id
|
||||
ORDER BY wo.LOT_ID, wo.SPLIT_ID, wo.SUB_ID
|
||||
|
||||
STATUS is a one-char code (R=released, C=closed, etc.) — map it to a
|
||||
readable label for work_order_status.
|
||||
|
||||
* Customer enrichment goes through the demand/supply linkage:
|
||||
DEMAND_SUPPLY_LINK rows with SUPPLY_TYPE='WO' and SUPPLY_BASE_ID =
|
||||
wo.BASE_ID (match SUPPLY_LOT_ID/SUPPLY_SPLIT_ID/SUPPLY_SUB_ID when
|
||||
present) point at customer-order demand (DEMAND_TYPE='CO',
|
||||
DEMAND_BASE_ID = CUST_ORDER_LINE.CUST_ORDER_ID, DEMAND_SEQ_NO = line
|
||||
no). Join CUSTOMER_ORDER -> CUSTOMER for the customer name.
|
||||
|
||||
* Return JobInfoData(part_id=..., part_description=...,
|
||||
customer_name=..., work_order_status=..., source="visual").
|
||||
Return None when no WORK_ORDER row matches. Wrap connection errors in
|
||||
logging + return None so an ERP outage never blocks NCR entry.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
raise NotImplementedError(
|
||||
"VisualJobLookupService is a documented stub. Implement it per the "
|
||||
"notes in app/services/job_lookup.py, or set JOB_LOOKUP_PROVIDER=null. "
|
||||
f"(Configured VISUAL host: {settings.visual_db_host or 'unset'})"
|
||||
)
|
||||
|
||||
async def lookup(self, job_number: str) -> JobInfoData | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
_service: JobLookupService | None = None
|
||||
|
||||
|
||||
def get_job_lookup_service() -> JobLookupService:
|
||||
global _service
|
||||
if _service is None:
|
||||
provider = get_settings().job_lookup_provider
|
||||
if provider == "visual":
|
||||
_service = VisualJobLookupService() # raises: intentionally loud
|
||||
else:
|
||||
_service = NullJobLookupService()
|
||||
logger.info("Job lookup provider: %s", provider)
|
||||
return _service
|
||||
156
backend/app/services/notifications.py
Normal file
156
backend/app/services/notifications.py
Normal 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}"
|
||||
]
|
||||
56
backend/app/services/numbering.py
Normal file
56
backend/app/services/numbering.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Atomic NCR number allocation.
|
||||
|
||||
Format: NCR-YYYY-NNNN (zero-padded, per-calendar-year sequence).
|
||||
|
||||
Strategy: UPDATE-first on the per-year row in ncr_sequences. The UPDATE takes
|
||||
a row lock (InnoDB) / reserved write lock (SQLite) that is held until the
|
||||
enclosing transaction commits, so two concurrent submissions serialize and can
|
||||
never read the same sequence value. If the year row doesn't exist yet
|
||||
(first NCR of a new year), it is inserted inside a SAVEPOINT; a losing racer
|
||||
gets an IntegrityError, rolls back only the savepoint, and proceeds to the
|
||||
UPDATE which now finds the winner's row.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import NcrSequence
|
||||
from app.models.base import utcnow
|
||||
|
||||
|
||||
async def allocate_ncr_number(
|
||||
db: AsyncSession, now: datetime | None = None
|
||||
) -> tuple[str, int, int]:
|
||||
"""Allocate the next NCR number inside the caller's transaction.
|
||||
|
||||
Returns (ncr_number, year, seq). Must be called within the same
|
||||
transaction that inserts the NCR so the sequence row lock is held
|
||||
until commit.
|
||||
"""
|
||||
year = (now or utcnow()).year
|
||||
|
||||
result = await db.execute(
|
||||
update(NcrSequence)
|
||||
.where(NcrSequence.year == year)
|
||||
.values(last_seq=NcrSequence.last_seq + 1)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
# First NCR of this calendar year — create the sequence row.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add(NcrSequence(year=year, last_seq=0))
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
pass # another request created it first; fall through to UPDATE
|
||||
await db.execute(
|
||||
update(NcrSequence)
|
||||
.where(NcrSequence.year == year)
|
||||
.values(last_seq=NcrSequence.last_seq + 1)
|
||||
)
|
||||
|
||||
seq = (
|
||||
await db.execute(select(NcrSequence.last_seq).where(NcrSequence.year == year))
|
||||
).scalar_one()
|
||||
return f"NCR-{year}-{seq:04d}", year, seq
|
||||
64
backend/app/services/pdf.py
Normal file
64
backend/app/services/pdf.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Printable NCR PDF (WeasyPrint) — a clean single-document rendering of the
|
||||
complete NCR for hard-copy travelers and audits.
|
||||
|
||||
WeasyPrint is imported lazily so environments without the Pango/Cairo system
|
||||
libraries (e.g. unit tests) can still import the app.
|
||||
"""
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from app.domain import STAGE_LABELS, Stage
|
||||
from app.models import Ncr
|
||||
from app.models.base import utcnow
|
||||
from app.services.storage import attachment_abs_path
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(_TEMPLATES_DIR),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
|
||||
|
||||
def _render_html(ncr: Ncr) -> str:
|
||||
images = []
|
||||
other_files = []
|
||||
for att in ncr.attachments:
|
||||
entry = {
|
||||
"filename": att.original_filename,
|
||||
"uploaded_by": att.uploaded_by.display_name,
|
||||
"uploaded_at": att.uploaded_at,
|
||||
"size_kb": max(1, att.size_bytes // 1024),
|
||||
}
|
||||
path = attachment_abs_path(att.stored_path)
|
||||
if att.is_image and path.is_file():
|
||||
entry["src"] = path.as_uri()
|
||||
images.append(entry)
|
||||
else:
|
||||
other_files.append(entry)
|
||||
|
||||
template = _env.get_template("ncr_pdf.html")
|
||||
return template.render(
|
||||
ncr=ncr,
|
||||
stage_label=STAGE_LABELS[Stage(ncr.stage)],
|
||||
stage_labels=STAGE_LABELS,
|
||||
Stage=Stage,
|
||||
images=images,
|
||||
other_files=other_files,
|
||||
generated_at=utcnow(),
|
||||
)
|
||||
|
||||
|
||||
def _html_to_pdf(html: str) -> bytes:
|
||||
from weasyprint import HTML # lazy: needs Pango/Cairo system libs
|
||||
|
||||
return HTML(string=html).write_pdf()
|
||||
|
||||
|
||||
async def render_ncr_pdf(ncr: Ncr) -> bytes:
|
||||
html = _render_html(ncr)
|
||||
# WeasyPrint rendering is CPU-bound; keep it off the event loop.
|
||||
return await anyio.to_thread.run_sync(partial(_html_to_pdf, html))
|
||||
31
backend/app/services/sanitize.py
Normal file
31
backend/app/services/sanitize.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Rich-text HTML sanitization (XSS defense) using nh3 (ammonia bindings).
|
||||
Applied server-side to every rich-text field before it is stored."""
|
||||
import nh3
|
||||
|
||||
_ALLOWED_TAGS = {
|
||||
"p", "br", "div", "span",
|
||||
"strong", "b", "em", "i", "u", "s", "sub", "sup",
|
||||
"ul", "ol", "li",
|
||||
"h1", "h2", "h3", "h4",
|
||||
"blockquote", "pre", "code",
|
||||
"a", "hr", "table", "thead", "tbody", "tr", "th", "td",
|
||||
}
|
||||
|
||||
_ALLOWED_ATTRIBUTES = {
|
||||
"a": {"href", "title"},
|
||||
"th": {"colspan", "rowspan"},
|
||||
"td": {"colspan", "rowspan"},
|
||||
}
|
||||
|
||||
|
||||
def sanitize_html(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = nh3.clean(
|
||||
value,
|
||||
tags=_ALLOWED_TAGS,
|
||||
attributes=_ALLOWED_ATTRIBUTES,
|
||||
link_rel="noopener noreferrer",
|
||||
url_schemes={"http", "https", "mailto"},
|
||||
)
|
||||
return cleaned
|
||||
87
backend/app/services/storage.py
Normal file
87
backend/app/services/storage.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Attachment storage on the local filesystem (a named Docker volume in
|
||||
production). Files live at ATTACHMENTS_DIR/<ncr_id>/<uuid><ext>; metadata is
|
||||
kept in the attachments table."""
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
ALLOWED_EXTENSIONS = {
|
||||
# images (camera capture on tablets produces jpg/png/heic)
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tiff", ".tif",
|
||||
# documents
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".txt", ".msg", ".eml",
|
||||
}
|
||||
|
||||
IMAGE_EXTENSIONS = {
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tiff", ".tif",
|
||||
}
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
class UploadValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
name = Path(name or "upload").name
|
||||
return re.sub(r"[^\w.\- ()]", "_", name)[:255] or "upload"
|
||||
|
||||
|
||||
async def save_attachment(upload: UploadFile, ncr_id: int) -> dict:
|
||||
"""Validate and persist an uploaded file. Returns metadata for the
|
||||
Attachment row. Raises UploadValidationError on type/size violations."""
|
||||
settings = get_settings()
|
||||
original = _safe_filename(upload.filename or "upload")
|
||||
ext = Path(original).suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise UploadValidationError(
|
||||
f"File type '{ext or 'unknown'}' is not allowed. "
|
||||
f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
|
||||
)
|
||||
|
||||
stored_rel = f"{ncr_id}/{uuid.uuid4().hex}{ext}"
|
||||
dest = Path(settings.attachments_dir) / stored_rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
size = 0
|
||||
max_bytes = settings.max_upload_bytes
|
||||
try:
|
||||
with dest.open("wb") as out:
|
||||
while chunk := await upload.read(CHUNK_SIZE):
|
||||
size += len(chunk)
|
||||
if size > max_bytes:
|
||||
raise UploadValidationError(
|
||||
f"File exceeds the {settings.max_upload_mb} MB limit."
|
||||
)
|
||||
out.write(chunk)
|
||||
except UploadValidationError:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise
|
||||
except Exception:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise
|
||||
if size == 0:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise UploadValidationError("Uploaded file is empty.")
|
||||
|
||||
return {
|
||||
"original_filename": original,
|
||||
"stored_path": stored_rel,
|
||||
"content_type": upload.content_type or "application/octet-stream",
|
||||
"size_bytes": size,
|
||||
"is_image": ext in IMAGE_EXTENSIONS,
|
||||
}
|
||||
|
||||
|
||||
def attachment_abs_path(stored_path: str) -> Path:
|
||||
settings = get_settings()
|
||||
base = Path(settings.attachments_dir).resolve()
|
||||
p = (base / stored_path).resolve()
|
||||
if not str(p).startswith(str(base)):
|
||||
raise UploadValidationError("Invalid attachment path.")
|
||||
return p
|
||||
76
backend/app/services/workflow.py
Normal file
76
backend/app/services/workflow.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Server-side workflow state machine. Every stage change flows through
|
||||
`transition()`, which validates against ALLOWED_TRANSITIONS and records both
|
||||
a StageTransition row (timestamps + acting user, for aging/cycle-time
|
||||
reporting) and an audit entry."""
|
||||
from app.domain import ALLOWED_TRANSITIONS, Stage
|
||||
from app.models import Ncr, StageTransition
|
||||
from app.models.base import utcnow
|
||||
from app.services.audit import audit_event
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class InvalidTransitionError(Exception):
|
||||
def __init__(self, from_stage: str, to_stage: str):
|
||||
self.from_stage = from_stage
|
||||
self.to_stage = to_stage
|
||||
super().__init__(f"Invalid stage transition: {from_stage} -> {to_stage}")
|
||||
|
||||
|
||||
def transition(
|
||||
db: AsyncSession,
|
||||
ncr: Ncr,
|
||||
to_stage: Stage,
|
||||
*,
|
||||
action: str,
|
||||
actor_id: int,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
from_stage = Stage(ncr.stage)
|
||||
if to_stage not in ALLOWED_TRANSITIONS.get(from_stage, set()):
|
||||
raise InvalidTransitionError(from_stage.value, to_stage.value)
|
||||
|
||||
now = utcnow()
|
||||
ncr.stage = to_stage.value
|
||||
ncr.stage_entered_at = now
|
||||
db.add(
|
||||
StageTransition(
|
||||
ncr_id=ncr.id,
|
||||
from_stage=from_stage.value,
|
||||
to_stage=to_stage.value,
|
||||
action=action,
|
||||
acted_by_id=actor_id,
|
||||
acted_at=now,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
audit_event(
|
||||
db,
|
||||
ncr_id=ncr.id,
|
||||
user_id=actor_id,
|
||||
action=action,
|
||||
field_name="stage",
|
||||
old_value=from_stage.value,
|
||||
new_value=to_stage.value,
|
||||
detail=note,
|
||||
)
|
||||
|
||||
|
||||
def record_creation(db: AsyncSession, ncr: Ncr, actor_id: int) -> None:
|
||||
db.add(
|
||||
StageTransition(
|
||||
ncr_id=ncr.id,
|
||||
from_stage=None,
|
||||
to_stage=Stage.NEW_REQUEST.value,
|
||||
action="create",
|
||||
acted_by_id=actor_id,
|
||||
acted_at=ncr.created_at,
|
||||
)
|
||||
)
|
||||
audit_event(
|
||||
db,
|
||||
ncr_id=ncr.id,
|
||||
user_id=actor_id,
|
||||
action="create",
|
||||
detail=f"NCR {ncr.ncr_number} created",
|
||||
)
|
||||
Reference in New Issue
Block a user