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:
312
backend/app/routers/admin.py
Normal file
312
backend/app/routers/admin.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user