71 lines
2.5 KiB
Python
71 lines
2.5 KiB
Python
"""Operational endpoints: diagnostics and backups (US07-05).
|
|
|
|
Backups can be taken and verified here because both are safe, additive, and the
|
|
operator needs them from the same screen that shows the disk filling up.
|
|
|
|
**Restore is deliberately not an endpoint.** It replaces the state of the running
|
|
application with an older one, so it belongs to a stopped installation and a person
|
|
at a terminal: ``python -m photo_pipeline restore``. An HTTP call that can silently
|
|
roll the library back to last week is a hole, not a feature.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Query, Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
from photo_pipeline.services import diagnostics
|
|
from photo_pipeline.services.backup import DEFAULT_KEEP, BackupError, BackupService
|
|
|
|
router = APIRouter(tags=["operations"])
|
|
|
|
|
|
class CreateBackupRequest(BaseModel):
|
|
reason: str = "manual"
|
|
keep: int = DEFAULT_KEEP
|
|
|
|
|
|
def _service(request: Request) -> BackupService:
|
|
return BackupService(request.app.state.config)
|
|
|
|
|
|
def _error(status: int, code: str, message: str) -> JSONResponse:
|
|
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
|
|
|
|
|
|
@router.get("/diagnostics")
|
|
def read_diagnostics(request: Request) -> dict:
|
|
return diagnostics.report(request.app.state.config)
|
|
|
|
|
|
@router.get("/backups")
|
|
def list_backups(request: Request) -> dict:
|
|
return {"backups": _service(request).list()}
|
|
|
|
|
|
@router.post("/backups", status_code=201)
|
|
def create_backup(body: CreateBackupRequest, request: Request):
|
|
try:
|
|
return _service(request).create(reason=body.reason, keep=body.keep)
|
|
except BackupError as error:
|
|
return _error(422, "backup_failed", str(error))
|
|
|
|
|
|
@router.get("/backups/{name}/verify")
|
|
def verify_backup(name: str, request: Request):
|
|
service = _service(request)
|
|
# The name comes from the browser, so it names a backup — it is never joined
|
|
# into a path until it has been matched against one that exists (US07-02).
|
|
if name not in {entry["name"] for entry in service.list()}:
|
|
return _error(404, "not_found", f"unknown backup {name}")
|
|
return {"name": name, **service.verify(service.root / name).as_dict()}
|
|
|
|
|
|
@router.post("/backups/prune")
|
|
def prune_backups(request: Request, keep: int = Query(DEFAULT_KEEP, ge=1)):
|
|
try:
|
|
return {"removed": _service(request).prune(keep=keep)}
|
|
except BackupError as error:
|
|
return _error(422, "invalid_retention", str(error))
|