"""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, ref: str | int) -> Ncr: """Resolve an NCR by its business identifier (e.g. NCR-2026-0021, case-insensitive) or — for backward compatibility with older links — by numeric database id.""" ref = str(ref).strip() if ref.isdigit(): ncr = await db.get(Ncr, int(ref)) else: ncr = ( await db.execute(select(Ncr).where(Ncr.ncr_number == ref.upper())) ).scalar_one_or_none() 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_ref}", response_model=NcrDetailOut) async def get_ncr( ncr_ref: str, current: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> NcrDetailOut: ncr = await _get_ncr(db, ncr_ref) return _detail(ncr, current) # ── stage actions ──────────────────────────────────────────────────────────── @router.post("/ncrs/{ncr_ref}/initial-disposition", response_model=NcrMutationOut) async def initial_disposition( ncr_ref: str, 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_ref) _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_ref}/secondary-disposition", response_model=NcrMutationOut) async def secondary_disposition( ncr_ref: str, 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_ref) _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_ref}/operations-complete", response_model=NcrMutationOut) async def operations_complete( ncr_ref: str, 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_ref) _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_ref}/inspection", response_model=NcrMutationOut) async def inspection( ncr_ref: str, 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_ref) _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_ref}/costing", response_model=NcrMutationOut) async def costing( ncr_ref: str, 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_ref) _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_ref}/reopen", response_model=NcrMutationOut) async def reopen( ncr_ref: str, 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_ref) 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_ref}/attachments", response_model=list[AttachmentOut], status_code=201) async def upload_attachments( ncr_ref: str, 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_ref) 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_ref}/audit", response_model=AuditListOut) async def ncr_audit( ncr_ref: str, 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 ncr = await _get_ncr(db, ncr_ref) 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_ref}/pdf") async def ncr_pdf( ncr_ref: str, 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_ref) 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"' }, )