Files
devplacepy/devplacepy/routers/admin/backups.py
T
retoorandClaude Sonnet 5 57087536e5 Attribute Devii AI spend to its invoking action, fix quiz question-at-a-time review, DB API/isslop result routes, workspace docs, and drop redundant docstrings
- Route Devii-driven AI gateway cost to the action/tool that triggered
  it instead of a blanket "internal" bucket, so per-feature AI spend
  is attributable.
- Fix the quiz attempt review to show one previously-answered question
  at a time instead of all of them at once, and stop a quiz endpoint
  linked from the quiz flow from responding with raw JSON.
- Add DB API async query result route and AI Usage Analyzer annotated
  source/media routes, with traversal-safe uid/path handling and
  matching tests.
- Add Code Farm action audit logging (plant/harvest/buy-plot/upgrade/
  fertilize) and related admin workspace/services/trash/gateway route
  and doc touch-ups.
- Drop redundant docstrings from access_tokens.py per the no-comments
  convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
2026-09-03 08:47:57 +02:00

375 lines
13 KiB
Python

# retoor <retoor@molodetz.nl>
import logging
from pathlib import Path
from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from devplacepy import config
from devplacepy.models import BackupRunForm, BackupScheduleForm
from devplacepy.responses import action_result, respond, wants_json
from devplacepy.schemas import BackupDashboardOut, BackupJobOut, BackupOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.audit import record as audit
from devplacepy.services.backup import store
from devplacepy.services.devii.tasks.schedule import cron_next, next_run, now_utc, to_iso
from devplacepy.services.jobs import queue
from devplacepy.utils import is_primary_admin, not_found, require_admin
from fastapi import Depends, HTTPException
from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
RETENTION_EXTEND_SECONDS = 7 * 24 * 60 * 60
def _download_url(row: dict, can_download: bool) -> str | None:
if (
can_download
and row.get("status") == store.STATUS_DONE
and row.get("local_path")
):
return f"/admin/backups/{row['uid']}/download"
return None
def _backup_payload(row: dict, can_download: bool) -> dict:
return {
**row,
"size_human": store.human_bytes(int(row.get("size_bytes") or 0)),
"download_url": _download_url(row, can_download),
}
def _metrics(backups: list[dict]) -> dict:
done = [b for b in backups if b["status"] == store.STATUS_DONE]
return {
"total": len(backups),
"done": len(done),
"running": len([b for b in backups if b["status"] == store.STATUS_RUNNING]),
"pending": len([b for b in backups if b["status"] == store.STATUS_PENDING]),
"failed": len([b for b in backups if b["status"] == store.STATUS_FAILED]),
"size_bytes": sum(int(b.get("size_bytes") or 0) for b in done),
"size_human": store.human_bytes(
sum(int(b.get("size_bytes") or 0) for b in done)
),
}
def _targets() -> list[dict]:
return [
{"key": key, "label": meta["label"], "description": meta["description"]}
for key, meta in store.BACKUP_TARGETS.items()
]
def _dashboard(can_download: bool) -> dict:
backups = [_backup_payload(row, can_download) for row in store.list_backups()]
schedules = store.list_schedules()
return {
"storage": store.compute_storage_stats(),
"backups": backups,
"schedules": schedules,
"targets": _targets(),
"metrics": _metrics(backups),
"generated_at": store.now_iso(),
"can_download_backups": can_download,
}
@router.get("/backups", response_class=HTMLResponse)
async def admin_backups(request: Request):
admin = require_admin(request)
data = _dashboard(is_primary_admin(admin))
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Backups - Admin",
description="Create, schedule, and manage encrypted-at-rest data backups.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"},
{"name": "Backups", "url": "/admin/backups"},
],
schemas=[website_schema(base)],
robots="noindex,nofollow",
)
return respond(
request,
"admin_backups.html",
{
**seo_ctx,
**data,
"request": request,
"user": admin,
"admin_section": "backups",
},
model=BackupDashboardOut,
)
@router.get("/backups/data")
async def admin_backups_data(request: Request):
admin = require_admin(request)
data = _dashboard(is_primary_admin(admin))
return JSONResponse(BackupDashboardOut.model_validate(data).model_dump(mode="json"))
@router.post("/backups/run")
async def admin_backups_run(
request: Request, data: Annotated[BackupRunForm, Depends(json_or_form(BackupRunForm))]
):
admin = require_admin(request)
job_uid = queue.enqueue(
"backup",
{"target": data.target, "schedule_uid": "", "created_by": admin["uid"]},
owner_kind="user",
owner_id=admin["uid"],
preferred_name=f"{store.target_label(data.target)} (manual)",
)
backup_uid = store.create_backup(
target=data.target, created_by=admin["uid"], job_uid=job_uid
)
audit.record(
request,
"admin.backup.run",
target_type="backup",
target_uid=backup_uid,
metadata={"target": data.target},
summary=f"started manual backup of {data.target}",
links=[audit.job(job_uid)],
)
if wants_json(request):
return JSONResponse(
{
"ok": True,
"uid": job_uid,
"backup_uid": backup_uid,
"status_url": f"/admin/backups/jobs/{job_uid}",
}
)
return action_result(request, "/admin/backups")
def _job_payload(job: dict, can_download: bool) -> dict:
result = job.get("result", {})
done = job.get("status") == queue.DONE
return {
"uid": job.get("uid", ""),
"kind": job.get("kind", ""),
"status": job.get("status", ""),
"target": (job.get("payload") or {}).get("target"),
"backup_uid": result.get("backup_uid") if done else None,
"download_url": (
f"/admin/backups/{result.get('backup_uid')}/download"
if can_download and done and result.get("backup_uid")
else None
),
"error": job.get("error") or None,
"bytes_out": int(job.get("bytes_out") or 0),
"file_count": int(job.get("item_count") or 0),
"sha256": result.get("sha256"),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at") or None,
}
@router.get("/backups/jobs/{uid}")
async def admin_backups_job(request: Request, uid: str):
admin = require_admin(request)
job = queue.get_job(uid)
if not job or job.get("kind") != "backup":
raise not_found("Backup job not found")
return JSONResponse(
BackupJobOut.model_validate(
_job_payload(job, is_primary_admin(admin))
).model_dump(mode="json")
)
def _first_run(form: BackupScheduleForm) -> str:
reference = now_utc()
if form.kind == "cron":
return to_iso(cron_next(form.cron.strip(), reference))
moment = next_run("interval", form.every_seconds, None, reference)
return to_iso(moment) if moment else ""
@router.post("/backups/schedules/create")
async def admin_backups_schedule_create(
request: Request, data: Annotated[BackupScheduleForm, Depends(json_or_form(BackupScheduleForm))]
):
admin = require_admin(request)
schedule_uid = store.create_schedule(
name=data.name,
target=data.target,
kind=data.kind,
every_seconds=data.every_seconds,
cron=data.cron.strip(),
keep_last=data.keep_last,
created_by=admin["uid"],
next_run_at=_first_run(data),
)
audit.record(
request,
"admin.backup_schedule.create",
target_type="backup_schedule",
target_uid=schedule_uid,
new_value=f"{data.name} -> {data.target}",
metadata={"target": data.target, "kind": data.kind},
summary=f"created backup schedule {data.name}",
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/edit")
async def admin_backups_schedule_edit(
request: Request, uid: str, data: Annotated[BackupScheduleForm, Depends(json_or_form(BackupScheduleForm))]
):
require_admin(request)
if not store.get_schedule(uid):
raise not_found("Schedule not found")
store.update_schedule(
uid,
{
"name": data.name,
"target": data.target,
"kind": data.kind,
"every_seconds": data.every_seconds,
"cron": data.cron.strip(),
"keep_last": data.keep_last,
"next_run_at": _first_run(data),
},
)
audit.record(
request,
"admin.backup_schedule.update",
target_type="backup_schedule",
target_uid=uid,
new_value=f"{data.name} -> {data.target}",
summary=f"updated backup schedule {data.name}",
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/toggle")
async def admin_backups_schedule_toggle(request: Request, uid: str):
require_admin(request)
schedule = store.get_schedule(uid)
if not schedule:
raise not_found("Schedule not found")
enabled = 0 if int(schedule.get("enabled") or 0) else 1
store.update_schedule(uid, {"enabled": enabled})
audit.record(
request,
"admin.backup_schedule.toggle",
target_type="backup_schedule",
target_uid=uid,
new_value="enabled" if enabled else "disabled",
summary=f"{'enabled' if enabled else 'disabled'} backup schedule {schedule.get('name')}",
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/run")
async def admin_backups_schedule_run(request: Request, uid: str):
admin = require_admin(request)
schedule = store.get_schedule(uid)
if not schedule:
raise not_found("Schedule not found")
job_uid = queue.enqueue(
"backup",
{
"target": schedule["target"],
"schedule_uid": uid,
"created_by": admin["uid"],
"keep_last": int(schedule.get("keep_last") or 0),
},
owner_kind="user",
owner_id=admin["uid"],
preferred_name=f"{schedule.get('name')} (manual run)",
)
store.create_backup(
target=schedule["target"],
created_by=admin["uid"],
job_uid=job_uid,
schedule_uid=uid,
)
audit.record(
request,
"admin.backup.run",
target_type="backup_schedule",
target_uid=uid,
metadata={"target": schedule["target"], "schedule_uid": uid},
summary=f"manually ran backup schedule {schedule.get('name')}",
links=[audit.job(job_uid)],
)
return action_result(request, "/admin/backups")
@router.post("/backups/schedules/{uid}/delete")
async def admin_backups_schedule_delete(request: Request, uid: str):
admin = require_admin(request)
schedule = store.get_schedule(uid)
if not schedule:
raise not_found("Schedule not found")
store.delete_schedule(uid, admin["uid"])
audit.record(
request,
"admin.backup_schedule.delete",
target_type="backup_schedule",
target_uid=uid,
old_value=schedule.get("name"),
summary=f"deleted backup schedule {schedule.get('name')}",
)
return action_result(request, "/admin/backups")
@router.get("/backups/{uid}/download")
async def admin_backups_download(request: Request, uid: str):
admin = require_admin(request)
if not is_primary_admin(admin):
audit.record(
request,
"security.authz.denied",
user=admin,
result="denied",
summary=f"non-primary admin {admin['username']} denied backup download",
)
raise HTTPException(
status_code=403,
detail="Only the primary administrator may download backups",
)
row = store.get_backup(uid)
if not row or row.get("status") != store.STATUS_DONE:
raise not_found("Backup not available")
local_path = row.get("local_path") or ""
resolved = Path(local_path).resolve()
if (
not local_path
or not resolved.is_relative_to(config.BACKUPS_DIR.resolve())
or not resolved.is_file()
):
raise not_found("Backup not available")
return FileResponse(
resolved,
filename=row.get("filename", "backup.tar.gz"),
media_type="application/gzip",
)
@router.post("/backups/{uid}/delete")
async def admin_backups_delete(request: Request, uid: str):
require_admin(request)
row = store.get_backup(uid)
if not row:
raise not_found("Backup not found")
store.delete_backup(uid)
audit.record(
request,
"admin.backup.delete",
target_type="backup",
target_uid=uid,
old_value=row.get("filename"),
metadata={"target": row.get("target"), "size_bytes": row.get("size_bytes")},
summary=f"deleted backup {row.get('filename')}",
)
return action_result(request, "/admin/backups")
@router.get("/backups/{uid}")
async def admin_backups_detail(request: Request, uid: str):
admin = require_admin(request)
row = store.get_backup(uid)
if not row:
raise not_found("Backup not found")
return JSONResponse(
BackupOut.model_validate(
_backup_payload(row, is_primary_admin(admin))
).model_dump(mode="json")
)