313 lines
11 KiB
Python
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,
|
||
|
|
)
|