"""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()