107 lines
3.3 KiB
Python
107 lines
3.3 KiB
Python
"""Rename plan API (US04-01): build, inspect, and export plans.
|
|
|
|
Planning is read-only — it previews and validates but never touches the filesystem.
|
|
Applying a plan is a later story, so there is deliberately no apply endpoint here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from photo_pipeline.schemas import ApplyPlanRequest
|
|
from photo_pipeline.services.rename_apply import (
|
|
ApplyConflict,
|
|
ApplyError,
|
|
RenameApplyService,
|
|
)
|
|
from photo_pipeline.services.renames import RenameError, RenameService
|
|
|
|
router = APIRouter(tags=["renames"])
|
|
|
|
|
|
def _service(request: Request) -> RenameService:
|
|
return RenameService(
|
|
request.app.state.session_factory,
|
|
library_roots=tuple(request.app.state.config.library_roots),
|
|
)
|
|
|
|
|
|
def _apply_service(request: Request) -> RenameApplyService:
|
|
return RenameApplyService(
|
|
request.app.state.session_factory,
|
|
library_roots=tuple(request.app.state.config.library_roots),
|
|
)
|
|
|
|
|
|
def _error(status: int, code: str, message: str) -> JSONResponse:
|
|
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
|
|
|
|
|
|
@router.post("/rename-plans")
|
|
def build_plan(request: Request):
|
|
try:
|
|
return _service(request).build_plan()
|
|
except RenameError as error:
|
|
return _error(422, "nothing_to_plan", str(error))
|
|
|
|
|
|
@router.get("/rename-plans")
|
|
def list_plans(request: Request) -> dict:
|
|
return _service(request).list_plans()
|
|
|
|
|
|
@router.get("/rename-plans/{plan_id}")
|
|
def get_plan(plan_id: str, request: Request):
|
|
plan = _service(request).get(plan_id)
|
|
if plan is None:
|
|
return _error(404, "not_found", f"unknown rename plan {plan_id}")
|
|
return plan
|
|
|
|
|
|
@router.get("/rename-plans/{plan_id}/export")
|
|
def export_plan(plan_id: str, request: Request):
|
|
try:
|
|
return _service(request).export(plan_id)
|
|
except RenameError as error:
|
|
return _error(404, "not_found", str(error))
|
|
|
|
|
|
@router.post("/rename-plans/{plan_id}/apply")
|
|
def apply_plan(plan_id: str, body: ApplyPlanRequest, request: Request):
|
|
"""Apply a confirmed plan. The confirmation must carry the current plan version
|
|
(and may carry the checksum) so stale browser state cannot run a changed plan."""
|
|
try:
|
|
return _apply_service(request).apply(
|
|
plan_id,
|
|
expected_version=body.expected_version,
|
|
expected_checksum=body.expected_checksum,
|
|
)
|
|
except ApplyConflict as error:
|
|
return _error(409, "version_conflict", str(error))
|
|
except ApplyError as error:
|
|
return _error(422, "cannot_apply", str(error))
|
|
|
|
|
|
@router.get("/rename-recovery")
|
|
def recovery_status(request: Request) -> dict:
|
|
"""Every unresolved rename with its evidence-derived classification."""
|
|
journal = _apply_service(request).journal
|
|
return {
|
|
"blocks_mutation": journal.blocks_mutation(),
|
|
"items": journal.classify_all(),
|
|
}
|
|
|
|
|
|
@router.post("/rename-recovery/resolve")
|
|
def resolve_recovery(request: Request) -> dict:
|
|
return _apply_service(request).recover()
|
|
|
|
|
|
@router.post("/rename-plans/{plan_id}/rollback")
|
|
def rollback_plan(plan_id: str, request: Request):
|
|
try:
|
|
return _apply_service(request).rollback_plan(plan_id)
|
|
except ApplyError as error:
|
|
return _error(422, "cannot_rollback", str(error))
|