feat(audit): admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries, generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never per-row (a scheduler run touching thousands of videos is ONE row). Backend: - migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action, target_type/id, summary, before/after JSON, created_at). - app/audit.py: AuditAction constants (single source of truth, i18n'd on the client) + record() (adds to the caller's txn) + gc(retention_days). - producers wired: role change / suspend / delete / invite approve-deny-add / demo add-remove-reset / discovery purge (admin.py); config set/reset — secret VALUES never logged, key only (config.py); scheduler interval + maintenance tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job() choke point writes ONE run-summary row per run — manual runs + errors always, automatic runs only when they changed something (no no-op-poll spam). - retention: audit_gc scheduler job prunes rows older than audit_retention_days (sysconfig, default 180; 0 = keep forever). - admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails resolved, System for background) + clear (leaves one tamper-evidence row). Frontend: - new admin-only "Audit log" nav page (ScrollText) using the shared DataTable (first admin page to reuse it) — sort/filter/paginate/persist, action select filter, actor text filter, before→after detail, Clear-all with confirm. - AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type. - trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group + scheduler job labels; Audit config group (retention days).
This commit is contained in:
parent
c1d38eea21
commit
318fdc4812
35 changed files with 720 additions and 29 deletions
51
backend/alembic/versions/0054_audit_log.py
Normal file
51
backend/alembic/versions/0054_audit_log.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""audit_log: append-only admin/scheduler audit trail (who/what/when/before->after)
|
||||
|
||||
Revision ID: 0054_audit_log
|
||||
Revises: 0053_user_session_epoch
|
||||
Create Date: 2026-07-12
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0054_audit_log"
|
||||
down_revision: Union[str, None] = "0053_user_session_epoch"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
# SET NULL on user delete: keep the trail (it just becomes system-attributed).
|
||||
sa.Column(
|
||||
"actor_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("action", sa.String(length=48), nullable=False),
|
||||
sa.Column("target_type", sa.String(length=32), nullable=True),
|
||||
sa.Column("target_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("summary", sa.String(length=255), nullable=True),
|
||||
sa.Column("before", sa.JSON(), nullable=True),
|
||||
sa.Column("after", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_audit_log_actor_id", "audit_log", ["actor_id"])
|
||||
op.create_index("ix_audit_log_action", "audit_log", ["action"])
|
||||
op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_log_created_at", table_name="audit_log")
|
||||
op.drop_index("ix_audit_log_action", table_name="audit_log")
|
||||
op.drop_index("ix_audit_log_actor_id", table_name="audit_log")
|
||||
op.drop_table("audit_log")
|
||||
83
backend/app/audit.py
Normal file
83
backend/app/audit.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Admin/scheduler audit trail: an append-only record of who changed what and when.
|
||||
|
||||
Design (locked): log at ACTION / job-run granularity, NEVER per-row — a scheduler run that
|
||||
touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are
|
||||
only logged when they actually changed something (or errored); all admin actions and all manual
|
||||
runs are always logged. Secrets are never stored (config changes log the key name only).
|
||||
|
||||
`record()` only db.add()s the row — the caller commits it in the SAME transaction as the
|
||||
mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops
|
||||
its audit row too).
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AuditLog
|
||||
|
||||
|
||||
class AuditAction:
|
||||
"""Canonical `<entity>_<action>` audit keys — the single source of truth. User-facing labels
|
||||
are resolved on the client via i18n `auditActions.<key>` (mirrors quota.QuotaAction)."""
|
||||
|
||||
# User / roles / access
|
||||
USER_ROLE_CHANGE = "user_role_change"
|
||||
USER_SUSPEND = "user_suspend"
|
||||
USER_UNSUSPEND = "user_unsuspend"
|
||||
USER_DELETE = "user_delete"
|
||||
INVITE_APPROVE = "invite_approve"
|
||||
INVITE_DENY = "invite_deny"
|
||||
INVITE_ADD = "invite_add"
|
||||
DEMO_ADD = "demo_add"
|
||||
DEMO_REMOVE = "demo_remove"
|
||||
DEMO_RESET = "demo_reset"
|
||||
DISCOVERY_PURGE = "discovery_purge"
|
||||
# Config
|
||||
CONFIG_SET = "config_set"
|
||||
CONFIG_RESET = "config_reset"
|
||||
# Scheduler / sync
|
||||
SCHEDULER_INTERVAL = "scheduler_interval"
|
||||
MAINTENANCE_TUNE = "maintenance_tune"
|
||||
SYNC_PAUSE = "sync_pause"
|
||||
SYNC_RESUME = "sync_resume"
|
||||
# Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic)
|
||||
JOB_RUN = "job_run"
|
||||
# The log itself was cleared (a single row survives the clear as tamper-evidence)
|
||||
AUDIT_CLEAR = "audit_clear"
|
||||
|
||||
|
||||
def record(
|
||||
db: Session,
|
||||
actor_id: int | None,
|
||||
action: str,
|
||||
*,
|
||||
target_type: str | None = None,
|
||||
target_id: str | int | None = None,
|
||||
summary: str | None = None,
|
||||
before: dict | None = None,
|
||||
after: dict | None = None,
|
||||
) -> None:
|
||||
"""Append an audit row (NOT committed — the caller commits it with the mutation)."""
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=None if target_id is None else str(target_id),
|
||||
summary=(summary or None) and summary[:255],
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def gc(db: Session, retention_days: int) -> int:
|
||||
"""Delete audit rows older than `retention_days`. Returns the number removed. A
|
||||
retention_days <= 0 disables pruning (keep forever)."""
|
||||
if retention_days <= 0:
|
||||
return 0
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff))
|
||||
db.commit()
|
||||
return result.rowcount or 0
|
||||
|
|
@ -185,6 +185,9 @@ class Settings(BaseSettings):
|
|||
download_retention_days: int = 30
|
||||
download_gc_grace_days: int = 2
|
||||
download_gc_minutes: int = 360
|
||||
# Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs.
|
||||
audit_retention_days: int = 180
|
||||
audit_gc_minutes: int = 1440
|
||||
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
|
||||
download_layout: str = "plex"
|
||||
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from app.config import settings
|
|||
from app.db import SessionLocal
|
||||
from app.routes import (
|
||||
admin,
|
||||
audit as audit_routes,
|
||||
channels,
|
||||
config as config_routes,
|
||||
downloads,
|
||||
|
|
@ -146,6 +147,7 @@ app.include_router(public_routes.router)
|
|||
app.include_router(admin.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(scheduler_routes.router)
|
||||
app.include_router(audit_routes.router)
|
||||
app.include_router(quota.router)
|
||||
app.include_router(version.router)
|
||||
app.include_router(setup_routes.router)
|
||||
|
|
|
|||
|
|
@ -495,6 +495,30 @@ class QuotaEvent(Base):
|
|||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Append-only admin/scheduler audit trail: who did what, when, and (where it applies)
|
||||
the before->after values. `actor_id` is the acting admin; NULL means the scheduler /
|
||||
background did it (SET NULL on user delete keeps the trail). Logged at ACTION / job-run
|
||||
granularity — never per-row — so a scheduler run that touches thousands of videos is ONE
|
||||
row summarising the outcome. `before`/`after` are small JSON snapshots (secrets excluded)."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
actor_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(48), index=True)
|
||||
target_type: Mapped[str | None] = mapped_column(String(32))
|
||||
target_id: Mapped[str | None] = mapped_column(String(128))
|
||||
summary: Mapped[str | None] = mapped_column(String(255))
|
||||
before: Mapped[dict | None] = mapped_column(JSON)
|
||||
after: Mapped[dict | None] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
class AppState(Base):
|
||||
"""Single-row global app state (admin-controlled)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app import email as email_mod
|
||||
from app.audit import AuditAction
|
||||
from app.auth import (
|
||||
admin_user,
|
||||
count_admins,
|
||||
|
|
@ -74,6 +76,14 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
|||
inv.status = "approved" if approved else "denied"
|
||||
inv.decided_at = datetime.now(timezone.utc)
|
||||
inv.decided_by = admin.email
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.INVITE_APPROVE if approved else AuditAction.INVITE_DENY,
|
||||
target_type="invite",
|
||||
target_id=inv.id,
|
||||
summary=inv.email,
|
||||
)
|
||||
db.commit()
|
||||
return inv
|
||||
|
||||
|
|
@ -117,6 +127,7 @@ def add_invite(
|
|||
inv.status = "approved"
|
||||
inv.decided_at = datetime.now(timezone.utc)
|
||||
inv.decided_by = admin.email
|
||||
audit.record(db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email)
|
||||
db.commit()
|
||||
_activate_user_by_email(db, email)
|
||||
return _serialize(inv)
|
||||
|
|
@ -174,6 +185,12 @@ def set_user_role(
|
|||
):
|
||||
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
||||
changed = target.role != role
|
||||
if changed:
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.USER_ROLE_CHANGE,
|
||||
target_type="user", target_id=target.id, summary=target.email,
|
||||
before={"role": target.role}, after={"role": role},
|
||||
)
|
||||
target.role = role
|
||||
db.commit()
|
||||
if changed:
|
||||
|
|
@ -210,6 +227,12 @@ def set_user_suspended(
|
|||
):
|
||||
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
||||
was_suspended = target.is_suspended
|
||||
if was_suspended != suspended:
|
||||
audit.record(
|
||||
db, admin.id,
|
||||
AuditAction.USER_SUSPEND if suspended else AuditAction.USER_UNSUSPEND,
|
||||
target_type="user", target_id=target.id, summary=target.email,
|
||||
)
|
||||
target.is_suspended = suspended
|
||||
db.commit()
|
||||
if was_suspended and not suspended:
|
||||
|
|
@ -240,6 +263,11 @@ def admin_delete_user(
|
|||
)
|
||||
if target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
|
||||
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.USER_DELETE,
|
||||
target_type="user", target_id=target.id, summary=target.email,
|
||||
)
|
||||
purge_user(db, target, background)
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
|
@ -289,6 +317,7 @@ def add_demo_whitelist(
|
|||
added_by=admin.email,
|
||||
)
|
||||
db.add(row)
|
||||
audit.record(db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email)
|
||||
db.commit()
|
||||
return _serialize_demo(row)
|
||||
|
||||
|
|
@ -296,11 +325,12 @@ def add_demo_whitelist(
|
|||
@router.delete("/demo/whitelist/{entry_id}")
|
||||
def remove_demo_whitelist(
|
||||
entry_id: int,
|
||||
_: User = Depends(admin_user),
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
row = db.get(DemoWhitelist, entry_id)
|
||||
if row is not None:
|
||||
audit.record(db, admin.id, AuditAction.DEMO_REMOVE, target_type="demo", target_id=row.email, summary=row.email)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"deleted": entry_id}
|
||||
|
|
@ -375,21 +405,30 @@ def reset_demo_state(db: Session, demo: User) -> int:
|
|||
|
||||
@router.post("/demo/reset")
|
||||
def reset_demo(
|
||||
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
|
||||
same automatically (admin-tunable interval); this is the admin's on-demand button."""
|
||||
demo = get_or_create_demo_user(db)
|
||||
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}
|
||||
seeded = reset_demo_state(db, demo)
|
||||
audit.record(db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded")
|
||||
db.commit()
|
||||
return {"reset": True, "playlists_seeded": seeded}
|
||||
|
||||
|
||||
@router.post("/purge-discovery")
|
||||
def purge_discovery(
|
||||
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
|
||||
videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The
|
||||
scheduled discovery-cleanup job does the same on its interval; this is the on-demand button."""
|
||||
from app.sync.explore import purge_unkept_explored, purge_unkept_search
|
||||
|
||||
return {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}
|
||||
result = {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.DISCOVERY_PURGE,
|
||||
summary=", ".join(f"{k}: {v}" for k, v in result.items()) or None, after=result,
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
|
|
|
|||
71
backend/app/routes/audit.py
Normal file
71
backend/app/routes/audit.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""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.db import get_db
|
||||
from app.models import AuditLog, User
|
||||
from app.routes.admin import admin_user
|
||||
|
||||
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": row.created_at.isoformat() if row.created_at else None,
|
||||
# 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")
|
||||
db.commit()
|
||||
return {"cleared": n}
|
||||
|
|
@ -3,8 +3,10 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app import email as email_mod
|
||||
from app import sysconfig
|
||||
from app.audit import AuditAction
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.routes.admin import admin_user
|
||||
|
|
@ -21,7 +23,7 @@ def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) ->
|
|||
def set_config(
|
||||
key: str,
|
||||
payload: dict,
|
||||
_: User = Depends(admin_user),
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
s = sysconfig.spec(key)
|
||||
|
|
@ -34,22 +36,40 @@ def set_config(
|
|||
status_code=400,
|
||||
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
||||
)
|
||||
old = None if s.secret else sysconfig.get(db, key)
|
||||
try:
|
||||
sysconfig.set_value(db, key, payload["value"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
||||
# Never log a secret's value — only that it was changed, by whom.
|
||||
new = None if s.secret else sysconfig.get(db, key)
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.CONFIG_SET, target_type="config", target_id=key,
|
||||
summary=f"{key} = (secret updated)" if s.secret else f"{key} = {new}",
|
||||
before=None if s.secret else {"value": old},
|
||||
after=None if s.secret else {"value": new},
|
||||
)
|
||||
db.commit()
|
||||
return sysconfig.describe(db)
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
def reset_config(
|
||||
key: str,
|
||||
_: User = Depends(admin_user),
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if sysconfig.spec(key) is None:
|
||||
s = sysconfig.spec(key)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown config key")
|
||||
old = None if s.secret else sysconfig.get(db, key)
|
||||
sysconfig.reset(db, key)
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.CONFIG_RESET, target_type="config", target_id=key,
|
||||
summary=f"{key} → default",
|
||||
before=None if s.secret else {"value": old},
|
||||
)
|
||||
db.commit()
|
||||
return sysconfig.describe(db)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota, state, sysconfig
|
||||
from app import audit, quota, state, sysconfig
|
||||
from app.audit import AuditAction
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
|
|
@ -28,7 +29,7 @@ router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
|||
def set_job_interval(
|
||||
job_id: str,
|
||||
payload: dict,
|
||||
_: User = Depends(admin_user),
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
|
||||
|
|
@ -45,6 +46,11 @@ def set_job_interval(
|
|||
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
||||
)
|
||||
applied = apply_interval(db, job_id, minutes)
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.SCHEDULER_INTERVAL, target_type="job", target_id=job_id,
|
||||
summary=f"{job_id} → {applied} min/run", after={"interval_minutes": applied},
|
||||
)
|
||||
db.commit()
|
||||
return {"id": job_id, "interval_minutes": applied}
|
||||
|
||||
|
||||
|
|
@ -74,7 +80,7 @@ def run_all_now(user: User = Depends(admin_user)) -> dict:
|
|||
@router.patch("/maintenance")
|
||||
def set_maintenance_settings(
|
||||
payload: dict,
|
||||
_: User = Depends(admin_user),
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
|
||||
|
|
@ -91,7 +97,10 @@ def set_maintenance_settings(
|
|||
f"{state.MAINTENANCE_BATCH_MAX}"
|
||||
),
|
||||
)
|
||||
return {"revalidate_batch": state.set_maintenance_batch(db, value)}
|
||||
new = state.set_maintenance_batch(db, value)
|
||||
audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new})
|
||||
db.commit()
|
||||
return {"revalidate_batch": new}
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from sqlalchemy import and_, case, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota, state
|
||||
from app import audit, quota, state
|
||||
from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
|
|
@ -188,12 +189,16 @@ def deep_all(
|
|||
|
||||
|
||||
@router.post("/pause")
|
||||
def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
state.set_sync_paused(db, True)
|
||||
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
||||
db.commit()
|
||||
return {"paused": True}
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
state.set_sync_paused(db, False)
|
||||
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
||||
db.commit()
|
||||
return {"paused": False}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ from typing import Callable
|
|||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import progress, quota
|
||||
from app import audit, progress, quota
|
||||
from app.audit import AuditAction
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads.gc import run_download_gc
|
||||
|
|
@ -66,6 +67,7 @@ JOB_INTERVALS: dict[str, int] = {
|
|||
"plex_sync": settings.plex_sync_interval_min,
|
||||
"plex_watch_sync": settings.plex_watch_sync_interval_min,
|
||||
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
|
||||
"audit_gc": settings.audit_gc_minutes,
|
||||
}
|
||||
|
||||
# Sane bounds for an admin-set interval (minutes).
|
||||
|
|
@ -127,6 +129,35 @@ def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | Non
|
|||
logger.exception("failed to write completion notification for job %s", job_id)
|
||||
|
||||
|
||||
def _run_changed(result) -> bool:
|
||||
"""Did an automatic job run actually do something worth an audit row? (No-op polls don't.)"""
|
||||
if result is None:
|
||||
return False
|
||||
if isinstance(result, dict):
|
||||
return any(bool(v) for v in result.values())
|
||||
if isinstance(result, (int, float)):
|
||||
return result != 0
|
||||
return bool(result)
|
||||
|
||||
|
||||
def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
|
||||
"""Write ONE run-summary audit row per job run (never per-item). Manual runs (an admin
|
||||
clicked "run now" → actor_id set) and errors are always logged; automatic runs only when
|
||||
they changed something, so routine no-op polls don't spam the trail. Best-effort."""
|
||||
if actor_id is None and status == "ok" and not _run_changed(result):
|
||||
return
|
||||
try:
|
||||
audit.record(
|
||||
db, actor_id, AuditAction.JOB_RUN, target_type="job", target_id=job_id,
|
||||
summary=f"{job_id}: {status}" + (f" — {summary}" if summary else ""),
|
||||
after={"status": status, "summary": summary},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("failed to write audit row for job %s", job_id)
|
||||
|
||||
|
||||
def _job(name: str, fn) -> None:
|
||||
db = SessionLocal()
|
||||
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
||||
|
|
@ -142,6 +173,7 @@ def _job(name: str, fn) -> None:
|
|||
last_result="paused", progress=None)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
||||
_audit_run(db, actor_id, name, "skipped", "sync paused", None)
|
||||
return
|
||||
with progress.bind(sink):
|
||||
result = fn(db)
|
||||
|
|
@ -151,6 +183,7 @@ def _job(name: str, fn) -> None:
|
|||
last_result=summary, progress=None)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "ok", summary)
|
||||
_audit_run(db, actor_id, name, "ok", summary, result)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.exception("job %s failed", name)
|
||||
|
|
@ -159,6 +192,7 @@ def _job(name: str, fn) -> None:
|
|||
last_error=err, progress=None)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "error", err)
|
||||
_audit_run(db, actor_id, name, "error", err, None)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
|
@ -307,6 +341,16 @@ def _plex_watch_reconcile_job() -> None:
|
|||
_job("plex_watch_reconcile", run_plex_watch_reconcile)
|
||||
|
||||
|
||||
def _audit_gc_job() -> None:
|
||||
# Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep
|
||||
# forever). Pure DB, no quota.
|
||||
def work(db):
|
||||
from app import sysconfig
|
||||
return {"reaped": audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))}
|
||||
|
||||
_job("audit_gc", work)
|
||||
|
||||
|
||||
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
|
||||
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
|
||||
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||
|
|
@ -324,6 +368,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
|
|||
"plex_sync": _plex_sync_job,
|
||||
"plex_watch_sync": _plex_watch_sync_job,
|
||||
"plex_watch_reconcile": _plex_watch_reconcile_job,
|
||||
"audit_gc": _audit_gc_job,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
|
||||
ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16),
|
||||
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
|
||||
# --- Audit log ---
|
||||
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
|
||||
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
|
||||
# --- Plex integration (optional; local-file playback + Plex metadata) ---
|
||||
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
|
||||
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue