Files
pesco-ncr/backend/app/routers/admin.py
ang3l12 dea316b113 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>
2026-07-13 11:41:22 -06:00

313 lines
11 KiB
Python

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