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

866
backend/app/routers/ncrs.py Normal file
View File

@@ -0,0 +1,866 @@
"""NCR endpoints: creation, queues/search, stage actions (the workflow state
machine), attachments, audit history, CSV export, and the printable PDF.
Every stage action re-validates BOTH the caller's role and the NCR's current
stage server-side; the frontend's `available_actions` hints are advisory only.
"""
import csv
import io
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, Response, StreamingResponse
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user, require_roles
from app.database import get_db
from app.domain import STAGE_LABELS, Role, Stage
from app.models import (
Attachment,
Department,
DeviationCategory,
JobInfo,
Ncr,
NcrSecondaryAssignee,
User,
UserRole,
)
from app.models.base import utcnow
from app.schemas.ncr import (
AttachmentOut,
AuditEntryOut,
AuditListOut,
CostingIn,
InitialDispositionIn,
InspectionIn,
JobInfoOut,
NcrCreateIn,
NcrDetailOut,
NcrListItem,
NcrListOut,
NcrMutationOut,
ReopenIn,
SecondaryDispositionIn,
TransitionOut,
)
from app.schemas.user import UserRef
from app.services.audit import apply_field_updates, audit_event
from app.services.job_lookup import get_job_lookup_service
from app.services.notifications import NotifyEvent, send_stage_notification
from app.services.numbering import allocate_ncr_number
from app.services.sanitize import sanitize_html
from app.services.storage import (
UploadValidationError,
attachment_abs_path,
save_attachment,
)
from app.services.workflow import InvalidTransitionError, record_creation, transition
logger = logging.getLogger(__name__)
router = APIRouter(tags=["ncrs"])
_STAGE_ORDER = [
Stage.NEW_REQUEST,
Stage.SECONDARY_DISPOSITION,
Stage.OPERATIONS,
Stage.QC_INSPECTION,
Stage.COSTING,
Stage.CLOSED,
]
# ── helpers ──────────────────────────────────────────────────────────────────
async def _get_ncr(db: AsyncSession, ncr_id: int) -> Ncr:
ncr = await db.get(Ncr, ncr_id)
if ncr is None:
raise HTTPException(status_code=404, detail="NCR not found.")
return ncr
def _days_in_stage(ncr: Ncr) -> int:
return max(0, (utcnow() - ncr.stage_entered_at).days)
def _ensure_stage(ncr: Ncr, expected: Stage) -> None:
if ncr.stage == Stage.CLOSED.value and expected != Stage.CLOSED:
raise HTTPException(
status_code=409,
detail=f"{ncr.ncr_number} is closed and locked. Only an Admin can reopen it.",
)
if ncr.stage != expected.value:
raise HTTPException(
status_code=409,
detail=(
f"{ncr.ncr_number} is in stage '{STAGE_LABELS[Stage(ncr.stage)]}', "
f"but this action requires '{STAGE_LABELS[expected]}'."
),
)
def _is_secondary_assignee(ncr: Ncr, current: CurrentUser) -> bool:
return any(row.user_id == current.id for row in ncr.secondary_assignee_rows)
def _available_actions(ncr: Ncr, current: CurrentUser) -> list[str]:
actions: list[str] = []
stage = Stage(ncr.stage)
if stage == Stage.NEW_REQUEST and current.has_role(Role.DISPOSITION_AUTHORITY):
actions.append("initial_disposition")
if stage == Stage.SECONDARY_DISPOSITION and (
current.is_admin or _is_secondary_assignee(ncr, current)
):
actions.append("secondary_disposition")
if stage == Stage.OPERATIONS and current.has_role(Role.OPERATIONS):
actions.append("operations_complete")
if stage == Stage.QC_INSPECTION and current.has_role(Role.QC_INSPECTOR):
actions.append("inspection")
if stage == Stage.COSTING and current.has_role(Role.COSTING):
actions.append("costing")
if stage == Stage.CLOSED and current.is_admin:
actions.append("reopen")
if stage != Stage.CLOSED:
actions.append("add_attachment")
if current.has_role(Role.QC_INSPECTOR): # admins pass automatically
actions.append("view_audit")
return actions
def _detail(ncr: Ncr, current: CurrentUser) -> NcrDetailOut:
stage = Stage(ncr.stage)
return NcrDetailOut(
id=ncr.id,
ncr_number=ncr.ncr_number,
job_number=ncr.job_number,
created_at=ncr.created_at,
stage=stage.value,
stage_label=STAGE_LABELS[stage],
stage_entered_at=ncr.stage_entered_at,
days_in_stage=_days_in_stage(ncr),
department=ncr.department.name,
department_id=ncr.department_id,
deviation_category=ncr.deviation_category.name,
deviation_category_id=ncr.deviation_category_id,
deviation_detail=ncr.deviation_detail,
requester=UserRef.model_validate(ncr.requester),
disposition_authority=UserRef.model_validate(ncr.disposition_authority),
qc_authority=ncr.qc_authority,
work_order=ncr.work_order,
disposition_notes=ncr.disposition_notes,
secondary_review_needed=ncr.secondary_review_needed,
secondary_authorities=[
UserRef.model_validate(u) for u in ncr.secondary_authorities
],
operations_complete=ncr.operations_complete,
operations_completed_at=ncr.operations_completed_at,
operations_completed_by=(
UserRef.model_validate(ncr.operations_completed_by)
if ncr.operations_completed_by
else None
),
qc_approval=ncr.qc_approval,
inspection_notes=ncr.inspection_notes,
qc_closed=ncr.qc_closed,
qc_closed_at=ncr.qc_closed_at,
qc_closed_by=(
UserRef.model_validate(ncr.qc_closed_by) if ncr.qc_closed_by else None
),
labor_cost=ncr.labor_cost,
material_cost=ncr.material_cost,
service_cost=ncr.service_cost,
other_cost=ncr.other_cost,
total_cost=ncr.total_cost,
costing_completed_at=ncr.costing_completed_at,
costing_completed_by=(
UserRef.model_validate(ncr.costing_completed_by)
if ncr.costing_completed_by
else None
),
closed_at=ncr.closed_at,
closed_by=UserRef.model_validate(ncr.closed_by) if ncr.closed_by else None,
job_info=JobInfoOut.model_validate(ncr.job_info) if ncr.job_info else None,
attachments=[AttachmentOut.model_validate(a) for a in ncr.attachments],
transitions=[TransitionOut.model_validate(t) for t in ncr.transitions],
available_actions=_available_actions(ncr, current),
)
async def _refetch(db: AsyncSession, ncr_id: int) -> Ncr:
"""Reload the NCR with fresh relationship collections after a commit."""
db.expire_all()
return await _get_ncr(db, ncr_id)
# ── create ───────────────────────────────────────────────────────────────────
@router.post("/ncrs", response_model=NcrMutationOut, status_code=201)
async def create_ncr(
payload: NcrCreateIn,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 1 — New Request. Open to every authenticated user."""
dept = await db.get(Department, payload.department_id)
if dept is None or not dept.is_active:
raise HTTPException(status_code=422, detail="Unknown or inactive department.")
cat = await db.get(DeviationCategory, payload.deviation_category_id)
if cat is None or not cat.is_active:
raise HTTPException(status_code=422, detail="Unknown or inactive deviation category.")
authority = await db.get(User, payload.disposition_authority_id)
if (
authority is None
or not authority.is_active
or Role.DISPOSITION_AUTHORITY.value not in authority.roles
):
raise HTTPException(
status_code=422,
detail="Selected disposition authority does not hold the Disposition Authority role.",
)
# External enrichment BEFORE the numbering lock so a slow ERP lookup can
# never serialize submissions. NullJobLookupService returns instantly.
job_info_data = None
try:
job_info_data = await get_job_lookup_service().lookup(payload.job_number)
except Exception:
logger.exception("Job lookup failed for %s (non-blocking)", payload.job_number)
ncr_number, year, seq = await allocate_ncr_number(db)
ncr = Ncr(
ncr_number=ncr_number,
ncr_year=year,
ncr_seq=seq,
job_number=payload.job_number.strip(),
department_id=payload.department_id,
deviation_category_id=payload.deviation_category_id,
disposition_authority_id=payload.disposition_authority_id,
deviation_detail=payload.deviation_detail,
requester_id=current.id,
stage=Stage.NEW_REQUEST.value,
)
db.add(ncr)
await db.flush()
record_creation(db, ncr, current.id)
if job_info_data is not None:
db.add(
JobInfo(
ncr_id=ncr.id,
part_id=job_info_data.part_id,
part_description=job_info_data.part_description,
customer_name=job_info_data.customer_name,
work_order_status=job_info_data.work_order_status,
source=job_info_data.source,
)
)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.CREATED,
current,
f"{current.user.display_name} submitted a new NCR and selected you as the "
"disposition authority.",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
# ── queues / search / export ────────────────────────────────────────────────
def _apply_filters(
stmt,
*,
q: str | None,
job_number: str | None,
department_id: int | None,
category_id: int | None,
stage: str | None,
date_from: str | None,
date_to: str | None,
disposition_authority_id: int | None,
):
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(or_(Ncr.ncr_number.like(like), Ncr.job_number.like(like)))
if job_number:
stmt = stmt.where(Ncr.job_number.like(f"%{job_number.strip()}%"))
if department_id:
stmt = stmt.where(Ncr.department_id == department_id)
if category_id:
stmt = stmt.where(Ncr.deviation_category_id == category_id)
if stage:
stmt = stmt.where(Ncr.stage == stage)
if date_from:
stmt = stmt.where(Ncr.created_at >= date_from)
if date_to:
stmt = stmt.where(Ncr.created_at <= f"{date_to} 23:59:59")
if disposition_authority_id:
stmt = stmt.where(Ncr.disposition_authority_id == disposition_authority_id)
return stmt
def _queue_filter(stmt, queue: str, current: CurrentUser):
if queue == "my_requests":
return stmt.where(Ncr.requester_id == current.id)
if queue == "new_requests":
return stmt.where(Ncr.stage == Stage.NEW_REQUEST.value)
if queue == "secondary":
return stmt.where(
Ncr.stage == Stage.SECONDARY_DISPOSITION.value,
Ncr.id.in_(
select(NcrSecondaryAssignee.ncr_id).where(
NcrSecondaryAssignee.user_id == current.id
)
),
)
if queue == "operations":
return stmt.where(Ncr.stage == Stage.OPERATIONS.value)
if queue == "inspection":
return stmt.where(Ncr.stage == Stage.QC_INSPECTION.value)
if queue == "costing":
return stmt.where(Ncr.stage == Stage.COSTING.value)
if queue == "recently_closed":
return stmt.where(Ncr.stage == Stage.CLOSED.value)
if queue in ("all", ""):
return stmt
raise HTTPException(status_code=422, detail=f"Unknown queue '{queue}'.")
def _list_item(ncr: Ncr) -> NcrListItem:
return NcrListItem(
id=ncr.id,
ncr_number=ncr.ncr_number,
job_number=ncr.job_number,
department=ncr.department.name,
deviation_category=ncr.deviation_category.name,
requester=ncr.requester.display_name,
disposition_authority=ncr.disposition_authority.display_name,
stage=ncr.stage,
stage_label=STAGE_LABELS[Stage(ncr.stage)],
days_in_stage=_days_in_stage(ncr),
created_at=ncr.created_at,
)
@router.get("/ncrs", response_model=NcrListOut)
async def list_ncrs(
queue: str = Query(default="all"),
q: str | None = None,
job_number: str | None = None,
department_id: int | None = None,
category_id: int | None = None,
stage: str | None = None,
date_from: str | None = Query(default=None, description="YYYY-MM-DD"),
date_to: str | None = Query(default=None, description="YYYY-MM-DD"),
disposition_authority_id: int | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=200),
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrListOut:
stmt = select(Ncr)
stmt = _queue_filter(stmt, queue, current)
stmt = _apply_filters(
stmt,
q=q,
job_number=job_number,
department_id=department_id,
category_id=category_id,
stage=stage,
date_from=date_from,
date_to=date_to,
disposition_authority_id=disposition_authority_id,
)
total = (
await db.execute(select(func.count()).select_from(stmt.subquery()))
).scalar_one()
order = Ncr.closed_at.desc() if queue == "recently_closed" else Ncr.created_at.desc()
rows = (
(await db.execute(stmt.order_by(order).offset((page - 1) * page_size).limit(page_size)))
.scalars()
.unique()
.all()
)
return NcrListOut(
items=[_list_item(n) for n in rows], total=total, page=page, page_size=page_size
)
_CSV_COLUMNS = [
"ncr_number", "job_number", "department", "deviation_category", "requester",
"disposition_authority", "stage", "days_in_stage", "created_at", "work_order",
"qc_authority", "secondary_review_needed", "operations_complete", "qc_approval",
"qc_closed", "labor_cost", "material_cost", "service_cost", "other_cost",
"total_cost", "closed_at",
]
@router.get("/ncrs/export.csv")
async def export_ncrs_csv(
queue: str = Query(default="all"),
q: str | None = None,
job_number: str | None = None,
department_id: int | None = None,
category_id: int | None = None,
stage: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
disposition_authority_id: int | None = None,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> StreamingResponse:
"""CSV export of any queue/search view (same filters as GET /ncrs)."""
stmt = select(Ncr)
stmt = _queue_filter(stmt, queue, current)
stmt = _apply_filters(
stmt,
q=q,
job_number=job_number,
department_id=department_id,
category_id=category_id,
stage=stage,
date_from=date_from,
date_to=date_to,
disposition_authority_id=disposition_authority_id,
)
rows = (
(await db.execute(stmt.order_by(Ncr.created_at.desc()).limit(20000)))
.scalars()
.unique()
.all()
)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_COLUMNS)
for n in rows:
writer.writerow(
[
n.ncr_number, n.job_number, n.department.name, n.deviation_category.name,
n.requester.display_name, n.disposition_authority.display_name,
STAGE_LABELS[Stage(n.stage)], _days_in_stage(n),
n.created_at.isoformat(sep=" "), n.work_order or "", n.qc_authority or "",
n.secondary_review_needed, n.operations_complete, n.qc_approval or "",
n.qc_closed, n.labor_cost or "", n.material_cost or "",
n.service_cost or "", n.other_cost or "", n.total_cost or "",
n.closed_at.isoformat(sep=" ") if n.closed_at else "",
]
)
buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="ncr-export.csv"'},
)
@router.get("/ncrs/{ncr_id}", response_model=NcrDetailOut)
async def get_ncr(
ncr_id: int,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrDetailOut:
ncr = await _get_ncr(db, ncr_id)
return _detail(ncr, current)
# ── stage actions ────────────────────────────────────────────────────────────
@router.post("/ncrs/{ncr_id}/initial-disposition", response_model=NcrMutationOut)
async def initial_disposition(
ncr_id: int,
payload: InitialDispositionIn,
current: CurrentUser = Depends(require_roles(Role.DISPOSITION_AUTHORITY)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 2 — Initial Disposition, performed on a New Request. Routes to
Secondary Disposition (when secondary review is needed) or Operations."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.NEW_REQUEST)
assignees: list[User] = []
if payload.secondary_review_needed:
if not payload.secondary_authority_ids:
raise HTTPException(
status_code=422,
detail="Secondary review requires at least one person in 'Notify These People'.",
)
for uid in set(payload.secondary_authority_ids):
u = await db.get(User, uid)
if (
u is None
or not u.is_active
or Role.SECONDARY_DISPOSITION_AUTHORITY.value not in u.roles
):
raise HTTPException(
status_code=422,
detail="All selected people must hold the Secondary Disposition Authority role.",
)
assignees.append(u)
updates = payload.model_dump(exclude_unset=True, exclude={"secondary_authority_ids"})
if "disposition_notes" in updates:
updates["disposition_notes"] = sanitize_html(updates["disposition_notes"])
updates["secondary_review_needed"] = payload.secondary_review_needed
apply_field_updates(db, ncr, current.id, updates, action="initial_disposition")
if payload.secondary_review_needed:
await db.execute(
delete(NcrSecondaryAssignee).where(NcrSecondaryAssignee.ncr_id == ncr.id)
)
for u in assignees:
db.add(NcrSecondaryAssignee(ncr_id=ncr.id, user_id=u.id))
audit_event(
db,
ncr_id=ncr.id,
user_id=current.id,
action="initial_disposition",
field_name="secondary_authorities",
new_value=", ".join(u.display_name for u in assignees),
)
_do_transition(db, ncr, Stage.SECONDARY_DISPOSITION, "initial_disposition", current)
event, summary = (
NotifyEvent.SECONDARY_ASSIGNED,
f"{current.user.display_name} completed initial disposition and assigned "
"you for secondary disposition review.",
)
else:
_do_transition(db, ncr, Stage.OPERATIONS, "initial_disposition", current)
event, summary = (
NotifyEvent.RELEASED_TO_OPERATIONS,
f"{current.user.display_name} completed initial disposition; the NCR is "
"ready for Operations.",
)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(db, ncr, event, current, summary)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/secondary-disposition", response_model=NcrMutationOut)
async def secondary_disposition(
ncr_id: int,
payload: SecondaryDispositionIn,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 3 — Secondary Disposition. Only the assigned secondary
authorities (or an Admin) may update or release to Operations."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.SECONDARY_DISPOSITION)
if not (current.is_admin or _is_secondary_assignee(ncr, current)):
raise HTTPException(
status_code=403,
detail="Only the assigned secondary disposition authority can act on this NCR.",
)
updates = payload.model_dump(exclude_unset=True, exclude={"release"})
if "disposition_notes" in updates:
updates["disposition_notes"] = sanitize_html(updates["disposition_notes"])
apply_field_updates(db, ncr, current.id, updates, action="secondary_disposition")
warnings: list[str] = []
if payload.release:
_do_transition(db, ncr, Stage.OPERATIONS, "secondary_release", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.RELEASED_TO_OPERATIONS,
current,
f"{current.user.display_name} completed secondary disposition review and "
"released the NCR to Operations.",
)
else:
await db.commit()
ncr = await _refetch(db, ncr.id)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/operations-complete", response_model=NcrMutationOut)
async def operations_complete(
ncr_id: int,
current: CurrentUser = Depends(require_roles(Role.OPERATIONS)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 4 — Operations marks rework complete; NCR moves to QC Inspection."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.OPERATIONS)
apply_field_updates(
db,
ncr,
current.id,
{
"operations_complete": True,
"operations_completed_at": utcnow(),
"operations_completed_by_id": current.id,
},
action="operations_complete",
)
_do_transition(db, ncr, Stage.QC_INSPECTION, "operations_complete", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.OPERATIONS_COMPLETE,
current,
f"{current.user.display_name} marked operations complete; the NCR is ready "
"for QC inspection.",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/inspection", response_model=NcrMutationOut)
async def inspection(
ncr_id: int,
payload: InspectionIn,
current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 5 — QC Inspection. QC can save repeatedly; checking QC Closed
advances the NCR to Costing."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.QC_INSPECTION)
updates = payload.model_dump(exclude_unset=True, exclude={"qc_closed"})
if payload.qc_closed:
updates.update(
{"qc_closed": True, "qc_closed_at": utcnow(), "qc_closed_by_id": current.id}
)
apply_field_updates(db, ncr, current.id, updates, action="inspection")
warnings: list[str] = []
if payload.qc_closed:
_do_transition(db, ncr, Stage.COSTING, "qc_close", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.QC_CLOSED,
current,
f"{current.user.display_name} closed QC inspection; the NCR is awaiting costing.",
)
else:
await db.commit()
ncr = await _refetch(db, ncr.id)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/costing", response_model=NcrMutationOut)
async def costing(
ncr_id: int,
payload: CostingIn,
current: CurrentUser = Depends(require_roles(Role.COSTING)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Stage 6 — Costing. Saving costs completes the workflow and closes the NCR."""
ncr = await _get_ncr(db, ncr_id)
_ensure_stage(ncr, Stage.COSTING)
now = utcnow()
apply_field_updates(
db,
ncr,
current.id,
{
"labor_cost": payload.labor_cost,
"material_cost": payload.material_cost,
"service_cost": payload.service_cost,
"other_cost": payload.other_cost,
"costing_completed_at": now,
"costing_completed_by_id": current.id,
"closed_at": now,
"closed_by_id": current.id,
},
action="costing",
)
_do_transition(db, ncr, Stage.CLOSED, "complete_costing", current)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.CLOSED,
current,
f"Costing is complete and your NCR has been closed. Total cost of "
f"nonconformance: ${ncr.total_cost:,.2f}.",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
@router.post("/ncrs/{ncr_id}/reopen", response_model=NcrMutationOut)
async def reopen(
ncr_id: int,
payload: ReopenIn,
current: CurrentUser = Depends(require_roles(Role.ADMIN)),
db: AsyncSession = Depends(get_db),
) -> NcrMutationOut:
"""Admin-only: reopen a closed NCR into a chosen prior stage. The reason
is required and recorded in the audit trail and transition history."""
ncr = await _get_ncr(db, ncr_id)
if ncr.stage != Stage.CLOSED.value:
raise HTTPException(status_code=409, detail="Only closed NCRs can be reopened.")
if payload.to_stage == Stage.SECONDARY_DISPOSITION and not ncr.secondary_assignee_rows:
raise HTTPException(
status_code=422,
detail="This NCR has no secondary authorities assigned; reopen it to "
"New Request so a disposition authority can assign them.",
)
target_idx = _STAGE_ORDER.index(payload.to_stage)
resets: dict = {"closed_at": None, "closed_by_id": None}
if target_idx <= _STAGE_ORDER.index(Stage.OPERATIONS):
resets.update(
{
"operations_complete": False,
"operations_completed_at": None,
"operations_completed_by_id": None,
}
)
if target_idx <= _STAGE_ORDER.index(Stage.QC_INSPECTION):
resets.update({"qc_closed": False, "qc_closed_at": None, "qc_closed_by_id": None})
if target_idx <= _STAGE_ORDER.index(Stage.COSTING):
resets.update({"costing_completed_at": None, "costing_completed_by_id": None})
apply_field_updates(db, ncr, current.id, resets, action="reopen")
_do_transition(
db, ncr, payload.to_stage, "reopen", current, note=f"Reopen reason: {payload.reason}"
)
await db.commit()
ncr = await _refetch(db, ncr.id)
warnings = await send_stage_notification(
db,
ncr,
NotifyEvent.REOPENED,
current,
f"{current.user.display_name} reopened this NCR to "
f"'{STAGE_LABELS[payload.to_stage]}'. Reason: {payload.reason}",
)
return NcrMutationOut(ncr=_detail(ncr, current), warnings=warnings)
def _do_transition(
db: AsyncSession,
ncr: Ncr,
to_stage: Stage,
action: str,
current: CurrentUser,
note: str | None = None,
) -> None:
try:
transition(db, ncr, to_stage, action=action, actor_id=current.id, note=note)
except InvalidTransitionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# ── attachments ──────────────────────────────────────────────────────────────
@router.post("/ncrs/{ncr_id}/attachments", response_model=list[AttachmentOut], status_code=201)
async def upload_attachments(
ncr_id: int,
files: list[UploadFile],
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[AttachmentOut]:
"""Photo/file attachments (multiple per request; camera capture on
tablets posts here too). Blocked once the NCR is closed."""
ncr = await _get_ncr(db, ncr_id)
if ncr.stage == Stage.CLOSED.value:
raise HTTPException(
status_code=409, detail="This NCR is closed; attachments are locked."
)
if not files:
raise HTTPException(status_code=422, detail="No files provided.")
saved: list[Attachment] = []
for f in files:
try:
meta = await save_attachment(f, ncr.id)
except UploadValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
att = Attachment(ncr_id=ncr.id, uploaded_by_id=current.id, **meta)
db.add(att)
audit_event(
db,
ncr_id=ncr.id,
user_id=current.id,
action="attachment_add",
field_name="attachments",
new_value=meta["original_filename"],
detail=f"{meta['size_bytes']} bytes, {meta['content_type']}",
)
saved.append(att)
await db.commit()
for att in saved:
await db.refresh(att)
return [AttachmentOut.model_validate(a) for a in saved]
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(
attachment_id: int,
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> FileResponse:
att = await db.get(Attachment, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="Attachment not found.")
path = attachment_abs_path(att.stored_path)
if not path.is_file():
raise HTTPException(status_code=404, detail="Attachment file missing from storage.")
return FileResponse(
path,
media_type=att.content_type,
filename=att.original_filename,
content_disposition_type="inline" if att.is_image else "attachment",
)
# ── audit history ────────────────────────────────────────────────────────────
@router.get("/ncrs/{ncr_id}/audit", response_model=AuditListOut)
async def ncr_audit(
ncr_id: int,
current: CurrentUser = Depends(require_roles(Role.QC_INSPECTOR)),
db: AsyncSession = Depends(get_db),
) -> AuditListOut:
"""Audit History tab — Admin and QC roles."""
from app.models import AuditLog
await _get_ncr(db, ncr_id)
rows = (
(
await db.execute(
select(AuditLog)
.where(AuditLog.ncr_id == ncr_id)
.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
)
)
.scalars()
.all()
)
return AuditListOut(
items=[AuditEntryOut.model_validate(r) for r in rows], total=len(rows)
)
# ── printable PDF ────────────────────────────────────────────────────────────
@router.get("/ncrs/{ncr_id}/pdf")
async def ncr_pdf(
ncr_id: int,
current: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Clean single-document rendering of the complete NCR for hard-copy
travelers and audits."""
from app.services.pdf import render_ncr_pdf
ncr = await _get_ncr(db, ncr_id)
pdf_bytes = await render_ncr_pdf(ncr)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{ncr.ncr_number}.pdf"'
},
)