235 lines
7.9 KiB
Python
235 lines
7.9 KiB
Python
"""Archive location, preflight, plan, and restore API (US06-01, US06-02, US06-04).
|
|
|
|
Registering a location writes a marker onto the medium; preflight is a command
|
|
rather than a read, because it probes the destination, hashes the scope, and issues
|
|
the token an archive plan must present. Creating a plan writes only database rows —
|
|
the transfer itself runs on the durable ``archive`` lane, never in the request
|
|
thread, because it removes originals.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel
|
|
|
|
from photo_pipeline.jobs.domain_handlers import ARCHIVE_LOCK, ARCHIVE_PLAN, RESTORE_PLAN
|
|
from photo_pipeline.services.archives import ArchiveError, ArchiveService
|
|
from photo_pipeline.services.archive_transfer import ArchiveTransferService
|
|
from photo_pipeline.services.jobs import JobBlocked, JobService
|
|
from photo_pipeline.services.restores import RestoreService
|
|
|
|
router = APIRouter(tags=["archives"])
|
|
|
|
# Which failures are the caller's request (422), a missing thing (404), or state
|
|
# that changed under the caller (409).
|
|
NOT_FOUND_CODES = {"unknown_location", "unknown_plan"}
|
|
CONFLICT_CODES = {"stale_token", "stale_plan", "archive_pending"}
|
|
|
|
|
|
class RegisterLocationRequest(BaseModel):
|
|
name: str
|
|
root: str
|
|
|
|
|
|
class PreflightRequest(BaseModel):
|
|
location_id: str
|
|
# ``None`` means every album; an explicit list scopes the check.
|
|
albums: list[str] | None = None
|
|
|
|
|
|
class CreatePlanRequest(PreflightRequest):
|
|
# The token of the preflight the user approved; a stale one is refused.
|
|
token: str
|
|
|
|
|
|
class RestoreRequest(BaseModel):
|
|
location_id: str
|
|
# ``None`` means every asset archived at this location.
|
|
asset_ids: list[str] | None = None
|
|
|
|
|
|
class CreateRestoreRequest(RestoreRequest):
|
|
token: str
|
|
|
|
|
|
def _service(request: Request) -> ArchiveService:
|
|
return ArchiveService(request.app.state.session_factory, config=request.app.state.config)
|
|
|
|
|
|
def _restores(request: Request) -> RestoreService:
|
|
return RestoreService(request.app.state.session_factory, config=request.app.state.config)
|
|
|
|
|
|
def _transfers(request: Request) -> ArchiveTransferService:
|
|
return ArchiveTransferService(
|
|
request.app.state.session_factory, config=request.app.state.config
|
|
)
|
|
|
|
|
|
def _error(error: ArchiveError) -> JSONResponse:
|
|
if error.code in NOT_FOUND_CODES:
|
|
status = 404
|
|
elif error.code in CONFLICT_CODES:
|
|
status = 409
|
|
else:
|
|
status = 422
|
|
return JSONResponse(
|
|
status_code=status, content={"error": {"code": error.code, "message": str(error)}}
|
|
)
|
|
|
|
|
|
@router.post("/archive-locations", status_code=201)
|
|
def register_location(body: RegisterLocationRequest, request: Request):
|
|
try:
|
|
return _service(request).register(body.name, body.root)
|
|
except ArchiveError as error:
|
|
return _error(error)
|
|
|
|
|
|
@router.get("/archive-locations")
|
|
def list_locations(request: Request) -> dict:
|
|
return {"locations": _service(request).locations()}
|
|
|
|
|
|
@router.post("/archive-preflight")
|
|
def preflight(body: PreflightRequest, request: Request):
|
|
try:
|
|
return _service(request).preflight(body.location_id, body.albums)
|
|
except ArchiveError as error:
|
|
return _error(error)
|
|
|
|
|
|
@router.post("/archive-plans", status_code=201)
|
|
def create_plan(body: CreatePlanRequest, request: Request):
|
|
"""Turn an approved preflight into a durable, journaled plan. Nothing moves."""
|
|
try:
|
|
return _transfers(request).create(body.location_id, body.albums, token=body.token)
|
|
except ArchiveError as error:
|
|
return _error(error)
|
|
|
|
|
|
@router.get("/archive-plans")
|
|
def list_plans(request: Request) -> dict:
|
|
return {"plans": _transfers(request).list()}
|
|
|
|
|
|
@router.get("/archive-plans/{plan_id}")
|
|
def get_plan(plan_id: str, request: Request):
|
|
plan = _transfers(request).get(plan_id)
|
|
if plan is None:
|
|
return _error(ArchiveError("unknown_plan", f"unknown archive plan {plan_id}"))
|
|
return plan
|
|
|
|
|
|
@router.post("/archive-plans/{plan_id}/apply")
|
|
def apply_plan(plan_id: str, request: Request):
|
|
"""Queue the transfer on the archiver lane. The worker removes the sources."""
|
|
service = _transfers(request)
|
|
plan = service.get(plan_id)
|
|
if plan is None:
|
|
return _error(ArchiveError("unknown_plan", f"unknown archive plan {plan_id}"))
|
|
if service.journal.blocks_mutation():
|
|
unresolved = [row for row in service.journal.incomplete() if row["plan_id"] != plan_id]
|
|
if unresolved:
|
|
return _error(
|
|
ArchiveError(
|
|
"archive_pending",
|
|
f"an unresolved archive operation ({unresolved[0]['id']}) must be recovered",
|
|
)
|
|
)
|
|
try:
|
|
job = JobService(request.app.state.session_factory).enqueue(
|
|
ARCHIVE_PLAN,
|
|
lock=ARCHIVE_LOCK,
|
|
# One queued attempt per plan version: a double-clicked apply reuses it.
|
|
idempotency_key=f"archive:{plan_id}:{plan['version']}",
|
|
items=[plan_id],
|
|
)
|
|
except JobBlocked as error:
|
|
return JSONResponse(
|
|
status_code=409, content={"error": {"code": error.code, "message": str(error)}}
|
|
)
|
|
return {"plan_id": plan_id, "job": job}
|
|
|
|
|
|
@router.post("/restore-preflight")
|
|
def restore_preflight(body: RestoreRequest, request: Request):
|
|
"""Validate restoring archived assets back into the library. Nothing moves."""
|
|
try:
|
|
return _restores(request).preflight(body.location_id, body.asset_ids)
|
|
except ArchiveError as error:
|
|
return _error(error)
|
|
|
|
|
|
@router.post("/restore-plans", status_code=201)
|
|
def create_restore_plan(body: CreateRestoreRequest, request: Request):
|
|
try:
|
|
return _restores(request).create(body.location_id, body.asset_ids, token=body.token)
|
|
except ArchiveError as error:
|
|
return _error(error)
|
|
|
|
|
|
@router.get("/restore-plans")
|
|
def list_restore_plans(request: Request) -> dict:
|
|
return {"plans": _restores(request).list()}
|
|
|
|
|
|
@router.get("/restore-plans/{plan_id}")
|
|
def get_restore_plan(plan_id: str, request: Request):
|
|
plan = _restores(request).get(plan_id)
|
|
if plan is None:
|
|
return _error(ArchiveError("unknown_plan", f"unknown restore plan {plan_id}"))
|
|
return plan
|
|
|
|
|
|
@router.post("/restore-plans/{plan_id}/apply")
|
|
def apply_restore_plan(plan_id: str, request: Request):
|
|
"""Queue the restore on the archiver lane — the same single lane as archiving,
|
|
because both move the same originals."""
|
|
service = _restores(request)
|
|
plan = service.get(plan_id)
|
|
if plan is None:
|
|
return _error(ArchiveError("unknown_plan", f"unknown restore plan {plan_id}"))
|
|
unresolved = [row for row in service.journal.incomplete() if row["plan_id"] != plan_id]
|
|
if unresolved:
|
|
return _error(
|
|
ArchiveError(
|
|
"archive_pending",
|
|
f"an unresolved archive operation ({unresolved[0]['id']}) must be recovered",
|
|
)
|
|
)
|
|
try:
|
|
job = JobService(request.app.state.session_factory).enqueue(
|
|
RESTORE_PLAN,
|
|
lock=ARCHIVE_LOCK,
|
|
idempotency_key=f"restore:{plan_id}:{plan['version']}",
|
|
items=[plan_id],
|
|
)
|
|
except JobBlocked as error:
|
|
return JSONResponse(
|
|
status_code=409, content={"error": {"code": error.code, "message": str(error)}}
|
|
)
|
|
return {"plan_id": plan_id, "job": job}
|
|
|
|
|
|
@router.get("/restore-recovery")
|
|
def restore_recovery_status(request: Request) -> dict:
|
|
return _restores(request).recovery_status()
|
|
|
|
|
|
@router.post("/restore-recovery/resolve")
|
|
def resolve_restore_recovery(request: Request) -> dict:
|
|
return _restores(request).recover()
|
|
|
|
|
|
@router.get("/archive-recovery")
|
|
def recovery_status(request: Request) -> dict:
|
|
"""What an interrupted transfer left behind, straight from journal + disk."""
|
|
return _transfers(request).recovery_status()
|
|
|
|
|
|
@router.post("/archive-recovery/resolve")
|
|
def resolve_recovery(request: Request) -> dict:
|
|
return _transfers(request).recover()
|