# 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(request: Request, 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(request, 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)],
)
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(request, 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")
)