- people_enrich: a failed TMDB lookup or a dead headshot is skipped, not the end of the run (a dead file is marked and left for a week; three failures in a row end it); portraits wait for the Plex module switch; a no-Plex original keeps its own cache key, so the resize still replaces it; the job entry lives in the plex domain (run_people_enrich) - person-image takes only a headshot-shaped CDN url (no query variants of one file) - every synced row gets Hungarian genres, and a HU genre filter matches the word as picked too; migration 0094 fills the rows no overlay reached - a job run's audit row carries its result as data; audit_clear / demo_reset store their counts - one credit-column list for the prune and the enrichment order; _overlay_pass typed - tests: batch skip + stop, HU bio stored, dead headshots, module switch, the person-image route, the job body, genre filter + facet write + twin fallback, tagline/summary fallbacks, audit data, the reader's alphabet at every remaining collated() call site
78 lines
2.7 KiB
Python
78 lines
2.7 KiB
Python
"""Admin audit log API: read the append-only who/what/when trail, and clear it.
|
|
|
|
Admin-only. The client renders these rows in a DataTable (client-side sort/filter/paginate), so
|
|
this returns the most recent window of rows (capped) plus the true total, rather than paging
|
|
server-side — the log stays small in practice (state-changing runs + admin actions only)."""
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app.audit import AuditAction
|
|
from app.auth import admin_user
|
|
from app.db import get_db
|
|
from app.models import AuditLog, User
|
|
from app.utils import iso
|
|
|
|
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
|
|
|
|
# Cap the client-side window. The audit log is small by design; if it ever outgrows this we'd
|
|
# switch the DataTable to server-side paging.
|
|
MAX_ROWS = 2000
|
|
|
|
|
|
def _serialize(row: AuditLog, actors: dict[int, str]) -> dict:
|
|
return {
|
|
"id": row.id,
|
|
"created_at": iso(row.created_at),
|
|
# actor_id NULL = the scheduler/background did it (client shows "System"); a set id that
|
|
# can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present.
|
|
"actor": actors.get(row.actor_id) if row.actor_id is not None else None,
|
|
"action": row.action,
|
|
"target_type": row.target_type,
|
|
"target_id": row.target_id,
|
|
"summary": row.summary,
|
|
"before": row.before,
|
|
"after": row.after,
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
def list_audit(
|
|
limit: int = 500,
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
limit = max(1, min(limit, MAX_ROWS))
|
|
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
|
|
rows = (
|
|
db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
actor_ids = {r.actor_id for r in rows if r.actor_id is not None}
|
|
actors: dict[int, str] = {}
|
|
if actor_ids:
|
|
actors = dict(db.execute(select(User.id, User.email).where(User.id.in_(actor_ids))).all())
|
|
return {
|
|
"items": [_serialize(r, actors) for r in rows],
|
|
"total": total,
|
|
"returned": len(rows),
|
|
}
|
|
|
|
|
|
@router.delete("")
|
|
def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
"""Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence)."""
|
|
n = db.execute(delete(AuditLog)).rowcount or 0
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.AUDIT_CLEAR,
|
|
summary=f"Cleared {n} audit entries",
|
|
after={"cleared": n}, # the count as data: the log names it in the reader's language
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "cleared": n}
|