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

View File

@@ -0,0 +1,312 @@
"""Admin area: role management, department/category lists, notification
toggle, and the global audit log. All endpoints are Admin-only."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, require_admin
from app.database import get_db
from app.domain import Role
from app.models import (
AppSetting,
AuditLog,
Department,
DeviationCategory,
Ncr,
User,
UserRole,
)
from app.models.app_setting import NOTIFICATIONS_ENABLED_KEY
from app.schemas.lookup import LookupCreateIn, LookupPatchIn, NamedLookupOut
from app.schemas.ncr import AuditEntryOut
from app.schemas.user import RolesUpdateIn, UserOut
from app.services.audit import audit_event
from app.services.notifications import notifications_enabled
router = APIRouter(prefix="/admin", tags=["admin"])
def _user_out(u: User) -> UserOut:
return UserOut(
id=u.id,
display_name=u.display_name,
email=u.email,
employee_id=u.employee_id,
is_active=u.is_active,
roles=u.roles,
last_login_at=u.last_login_at,
)
# ── users & roles ────────────────────────────────────────────────────────────
@router.get("/users", response_model=list[UserOut])
async def list_all_users(
search: str | None = None,
_: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> list[UserOut]:
stmt = select(User).order_by(User.display_name)
if search:
like = f"%{search.strip()}%"
stmt = stmt.where(User.display_name.like(like) | User.email.like(like))
users = (await db.execute(stmt)).scalars().unique().all()
return [_user_out(u) for u in users]
@router.put("/users/{user_id}/roles", response_model=UserOut)
async def set_user_roles(
user_id: int,
payload: RolesUpdateIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> UserOut:
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
if user.id == current.id and Role.ADMIN.value not in payload.roles:
raise HTTPException(
status_code=422,
detail="You cannot remove your own Admin role (lockout protection).",
)
old_roles = user.roles
user.role_rows = [UserRole(user_id=user.id, role=r) for r in payload.roles]
audit_event(
db,
user_id=current.id,
action="roles_update",
field_name=f"user:{user.email}",
old_value=", ".join(old_roles) or "(none)",
new_value=", ".join(payload.roles) or "(none)",
)
await db.commit()
await db.refresh(user)
return _user_out(user)
class ActivePatchIn(BaseModel):
is_active: bool
@router.put("/users/{user_id}/active", response_model=UserOut)
async def set_user_active(
user_id: int,
payload: ActivePatchIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> UserOut:
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
if user.id == current.id and not payload.is_active:
raise HTTPException(status_code=422, detail="You cannot deactivate yourself.")
if user.is_active != payload.is_active:
audit_event(
db,
user_id=current.id,
action="user_active",
field_name=f"user:{user.email}",
old_value=user.is_active,
new_value=payload.is_active,
)
user.is_active = payload.is_active
await db.commit()
await db.refresh(user)
return _user_out(user)
# ── departments & deviation categories ──────────────────────────────────────
# No hard-delete endpoints exist by design: values referenced by existing
# NCRs are only ever deactivated.
@router.get("/departments", response_model=list[NamedLookupOut])
async def list_departments(
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
):
rows = (await db.execute(select(Department).order_by(Department.name))).scalars().all()
return [NamedLookupOut.model_validate(r) for r in rows]
@router.post("/departments", response_model=NamedLookupOut, status_code=201)
async def create_department(
payload: LookupCreateIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _create_lookup(Department, "Department", payload, current, db)
@router.patch("/departments/{item_id}", response_model=NamedLookupOut)
async def patch_department(
item_id: int,
payload: LookupPatchIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _patch_lookup(Department, "Department", item_id, payload, current, db)
@router.get("/categories", response_model=list[NamedLookupOut])
async def list_categories(
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
):
rows = (
(await db.execute(select(DeviationCategory).order_by(DeviationCategory.name)))
.scalars()
.all()
)
return [NamedLookupOut.model_validate(r) for r in rows]
@router.post("/categories", response_model=NamedLookupOut, status_code=201)
async def create_category(
payload: LookupCreateIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _create_lookup(DeviationCategory, "Deviation category", payload, current, db)
@router.patch("/categories/{item_id}", response_model=NamedLookupOut)
async def patch_category(
item_id: int,
payload: LookupPatchIn,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
return await _patch_lookup(
DeviationCategory, "Deviation category", item_id, payload, current, db
)
async def _create_lookup(model, label, payload, current, db) -> NamedLookupOut:
exists = (
await db.execute(select(model).where(model.name == payload.name.strip()))
).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail=f"{label} already exists.")
row = model(name=payload.name.strip(), is_active=True)
db.add(row)
audit_event(
db, user_id=current.id, action="lookup_create", field_name=label, new_value=payload.name
)
await db.commit()
await db.refresh(row)
return NamedLookupOut.model_validate(row)
async def _patch_lookup(model, label, item_id, payload, current, db) -> NamedLookupOut:
row = await db.get(model, item_id)
if row is None:
raise HTTPException(status_code=404, detail=f"{label} not found.")
if payload.name is not None and payload.name.strip() != row.name:
audit_event(
db,
user_id=current.id,
action="lookup_rename",
field_name=label,
old_value=row.name,
new_value=payload.name.strip(),
)
row.name = payload.name.strip()
if payload.is_active is not None and payload.is_active != row.is_active:
audit_event(
db,
user_id=current.id,
action="lookup_active",
field_name=f"{label}: {row.name}",
old_value=row.is_active,
new_value=payload.is_active,
)
row.is_active = payload.is_active
await db.commit()
await db.refresh(row)
return NamedLookupOut.model_validate(row)
# ── settings ─────────────────────────────────────────────────────────────────
class SettingsOut(BaseModel):
notifications_enabled: bool
@router.get("/settings", response_model=SettingsOut)
async def get_admin_settings(
_: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db)
) -> SettingsOut:
return SettingsOut(notifications_enabled=await notifications_enabled(db))
@router.put("/settings", response_model=SettingsOut)
async def put_admin_settings(
payload: SettingsOut,
current: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> SettingsOut:
row = await db.get(AppSetting, NOTIFICATIONS_ENABLED_KEY)
old = await notifications_enabled(db)
if row is None:
row = AppSetting(
key=NOTIFICATIONS_ENABLED_KEY,
value="true" if payload.notifications_enabled else "false",
)
db.add(row)
else:
row.value = "true" if payload.notifications_enabled else "false"
if old != payload.notifications_enabled:
audit_event(
db,
user_id=current.id,
action="settings_update",
field_name=NOTIFICATIONS_ENABLED_KEY,
old_value=old,
new_value=payload.notifications_enabled,
)
await db.commit()
return SettingsOut(notifications_enabled=payload.notifications_enabled)
# ── global audit log ─────────────────────────────────────────────────────────
class GlobalAuditOut(BaseModel):
items: list[AuditEntryOut]
total: int
page: int
page_size: int
@router.get("/audit", response_model=GlobalAuditOut)
async def global_audit(
ncr_number: str | None = None,
action: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
_: CurrentUser = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> GlobalAuditOut:
stmt = select(AuditLog)
if ncr_number:
stmt = stmt.where(
AuditLog.ncr_id.in_(
select(Ncr.id).where(Ncr.ncr_number.like(f"%{ncr_number.strip()}%"))
)
)
if action:
stmt = stmt.where(AuditLog.action == action)
total = (
await db.execute(select(func.count()).select_from(stmt.subquery()))
).scalar_one()
rows = (
(
await db.execute(
stmt.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
)
.scalars()
.all()
)
return GlobalAuditOut(
items=[AuditEntryOut.model_validate(r) for r in rows],
total=total,
page=page,
page_size=page_size,
)

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict:
return {"status": "ok"}
@router.get("/health/db")
async def health_db(db: AsyncSession = Depends(get_db)) -> dict:
await db.execute(text("SELECT 1"))
return {"status": "ok", "database": "ok"}

View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from app.auth.deps import CurrentUser, get_current_user
from app.services.job_lookup import get_job_lookup_service
router = APIRouter(tags=["jobs"])
@router.get("/jobs/{job_number}/lookup")
async def lookup_job(
job_number: str,
_: CurrentUser = Depends(get_current_user),
) -> dict:
"""Job-number enrichment endpoint. Returns {found: false} under the
default NullJobLookupService; a future VisualJobLookupService will return
part/customer/work-order data from Infor VISUAL without frontend changes."""
info = await get_job_lookup_service().lookup(job_number)
if info is None:
return {"found": False, "job_number": job_number}
return {
"found": True,
"job_number": job_number,
"part_id": info.part_id,
"part_description": info.part_description,
"customer_name": info.customer_name,
"work_order_status": info.work_order_status,
"source": info.source,
}

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user
from app.database import get_db
from app.models import Department, DeviationCategory
from app.schemas.lookup import LookupsOut, NamedLookupOut
router = APIRouter(tags=["lookups"])
@router.get("/lookups", response_model=LookupsOut)
async def get_lookups(
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> LookupsOut:
"""Active departments and deviation categories for form dropdowns."""
departments = (
(
await db.execute(
select(Department)
.where(Department.is_active.is_(True))
.order_by(Department.name)
)
)
.scalars()
.all()
)
categories = (
(
await db.execute(
select(DeviationCategory)
.where(DeviationCategory.is_active.is_(True))
.order_by(DeviationCategory.name)
)
)
.scalars()
.all()
)
return LookupsOut(
departments=[NamedLookupOut.model_validate(d) for d in departments],
deviation_categories=[NamedLookupOut.model_validate(c) for c in categories],
)

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"'
},
)

View File

@@ -0,0 +1,185 @@
"""Built-in reports: counts, cost of nonconformance, aging, cycle times,
top jobs. All queries respect the shared date-range/department/category
filters."""
from collections import defaultdict
from decimal import Decimal
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user
from app.database import get_db
from app.domain import STAGE_LABELS, Stage
from app.models import Department, DeviationCategory, Ncr, StageTransition
from app.models.base import utcnow
from app.schemas.report import (
AgingBucket,
CostByMonth,
CountByMonth,
CountByName,
ReportsSummaryOut,
StageCycleTime,
TopJob,
)
router = APIRouter(tags=["reports"])
_AGING_BUCKETS = [(0, 7, "07 days"), (8, 14, "814 days"), (15, 30, "1530 days"),
(31, 60, "3160 days"), (61, None, "60+ days")]
def _base_filters(stmt, date_from, date_to, department_id, category_id):
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 department_id:
stmt = stmt.where(Ncr.department_id == department_id)
if category_id:
stmt = stmt.where(Ncr.deviation_category_id == category_id)
return stmt
@router.get("/reports/summary", response_model=ReportsSummaryOut)
async def reports_summary(
date_from: str | None = Query(default=None, description="YYYY-MM-DD"),
date_to: str | None = Query(default=None, description="YYYY-MM-DD"),
department_id: int | None = None,
category_id: int | None = None,
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ReportsSummaryOut:
filters = dict(
date_from=date_from,
date_to=date_to,
department_id=department_id,
category_id=category_id,
)
# Load the filtered NCR set once; aggregate in Python. NCR volume is a few
# thousand rows a year, so this stays cheap and keeps the SQL portable.
ncrs = (
(await db.execute(_base_filters(select(Ncr), **filters))).scalars().unique().all()
)
dept_names = {
d.id: d.name for d in (await db.execute(select(Department))).scalars().all()
}
cat_names = {
c.id: c.name
for c in (await db.execute(select(DeviationCategory))).scalars().all()
}
by_dept: dict[str, int] = defaultdict(int)
by_cat: dict[str, int] = defaultdict(int)
by_month: dict[str, int] = defaultdict(int)
cost_by_month: dict[str, dict[str, Decimal]] = defaultdict(
lambda: {"labor": Decimal(0), "material": Decimal(0), "service": Decimal(0), "other": Decimal(0)}
)
aging_counts: dict[str, int] = {label: 0 for _, _, label in _AGING_BUCKETS}
job_counts: dict[str, int] = defaultdict(int)
total_cost = Decimal(0)
open_count = 0
closed_count = 0
now = utcnow()
for n in ncrs:
by_dept[dept_names.get(n.department_id, "?")] += 1
by_cat[cat_names.get(n.deviation_category_id, "?")] += 1
by_month[n.created_at.strftime("%Y-%m")] += 1
job_counts[n.job_number] += 1
if n.stage == Stage.CLOSED.value:
closed_count += 1
month = (n.closed_at or n.created_at).strftime("%Y-%m")
bucket = cost_by_month[month]
bucket["labor"] += n.labor_cost or 0
bucket["material"] += n.material_cost or 0
bucket["service"] += n.service_cost or 0
bucket["other"] += n.other_cost or 0
total_cost += n.total_cost or 0
else:
open_count += 1
days = max(0, (now - n.stage_entered_at).days)
for lo, hi, label in _AGING_BUCKETS:
if days >= lo and (hi is None or days <= hi):
aging_counts[label] += 1
break
# ── cycle times from the transition history ─────────────────────────────
ncr_ids = [n.id for n in ncrs]
stage_durations: dict[str, list[float]] = defaultdict(list)
end_to_end: list[float] = []
if ncr_ids:
transitions = (
(
await db.execute(
select(StageTransition)
.where(StageTransition.ncr_id.in_(ncr_ids))
.order_by(StageTransition.ncr_id, StageTransition.acted_at)
)
)
.scalars()
.all()
)
per_ncr: dict[int, list[StageTransition]] = defaultdict(list)
for t in transitions:
per_ncr[t.ncr_id].append(t)
for items in per_ncr.values():
for prev, nxt in zip(items, items[1:]):
delta_days = (nxt.acted_at - prev.acted_at).total_seconds() / 86400
stage_durations[prev.to_stage].append(delta_days)
first, last = items[0], items[-1]
if last.to_stage == Stage.CLOSED.value:
end_to_end.append(
(last.acted_at - first.acted_at).total_seconds() / 86400
)
cycle_times = [
StageCycleTime(
stage=s.value,
stage_label=STAGE_LABELS[s],
avg_days=round(sum(v) / len(v), 2),
samples=len(v),
)
for s in Stage
if s != Stage.CLOSED and (v := stage_durations.get(s.value))
]
months = sorted(set(by_month) | set(cost_by_month))
return ReportsSummaryOut(
total_ncrs=len(ncrs),
open_ncrs=open_count,
closed_ncrs=closed_count,
total_cost=total_cost,
by_department=sorted(
(CountByName(name=k, count=v) for k, v in by_dept.items()),
key=lambda x: -x.count,
),
by_category=sorted(
(CountByName(name=k, count=v) for k, v in by_cat.items()),
key=lambda x: -x.count,
),
by_month=[CountByMonth(month=m, count=by_month.get(m, 0)) for m in months],
cost_over_time=[
CostByMonth(
month=m,
labor=c["labor"],
material=c["material"],
service=c["service"],
other=c["other"],
total=c["labor"] + c["material"] + c["service"] + c["other"],
)
for m in months
if (c := cost_by_month.get(m))
],
aging=[AgingBucket(bucket=label, count=aging_counts[label]) for _, _, label in _AGING_BUCKETS],
cycle_times=cycle_times,
end_to_end_avg_days=(
round(sum(end_to_end) / len(end_to_end), 2) if end_to_end else None
),
top_jobs=sorted(
(TopJob(job_number=j, count=c) for j, c in job_counts.items()),
key=lambda x: -x.count,
)[:10],
)

View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import CurrentUser, get_current_user
from app.config import get_settings
from app.database import get_db
from app.domain import ALL_ROLES
from app.models import User, UserRole
from app.schemas.user import MeOut, UserOut
router = APIRouter(tags=["users"])
@router.get("/me", response_model=MeOut)
async def get_me(current: CurrentUser = Depends(get_current_user)) -> MeOut:
u = current.user
return MeOut(
id=u.id,
display_name=u.display_name,
email=u.email,
employee_id=u.employee_id,
is_active=u.is_active,
roles=sorted(current.roles),
last_login_at=u.last_login_at,
auth_mode=get_settings().auth_mode,
)
@router.get("/users", response_model=list[UserOut])
async def list_users(
role: str | None = Query(default=None, description="Filter to users holding this role"),
_: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[UserOut]:
"""User directory for pickers (e.g. Disposition Authority dropdown,
'Notify These People'). Only active users are returned."""
stmt = select(User).where(User.is_active.is_(True)).order_by(User.display_name)
if role:
if role not in ALL_ROLES:
return []
stmt = stmt.join(UserRole, UserRole.user_id == User.id).where(UserRole.role == role)
users = (await db.execute(stmt)).scalars().unique().all()
return [
UserOut(
id=u.id,
display_name=u.display_name,
email=u.email,
employee_id=u.employee_id,
is_active=u.is_active,
roles=u.roles,
last_login_at=u.last_login_at,
)
for u in users
]