"""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, RootCauseCategory, 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 ) @router.get("/root-cause-categories", response_model=list[NamedLookupOut]) async def list_root_cause_categories( _: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db) ): rows = ( (await db.execute(select(RootCauseCategory).order_by(RootCauseCategory.name))) .scalars() .all() ) return [NamedLookupOut.model_validate(r) for r in rows] @router.post("/root-cause-categories", response_model=NamedLookupOut, status_code=201) async def create_root_cause_category( payload: LookupCreateIn, current: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db), ): return await _create_lookup(RootCauseCategory, "Root cause category", payload, current, db) @router.patch("/root-cause-categories/{item_id}", response_model=NamedLookupOut) async def patch_root_cause_category( item_id: int, payload: LookupPatchIn, current: CurrentUser = Depends(require_admin), db: AsyncSession = Depends(get_db), ): return await _patch_lookup( RootCauseCategory, "Root cause 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, )