"""Album evidence and naming-proposal API (US03-01/US03-03). Proposals are read, edited, and approved by album label. Edits and approvals carry an ``expected_version``; a stale version returns 409 without mutating anything. Approval records intent only — no rename happens in this phase. """ from __future__ import annotations from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse from photo_pipeline.schemas import ( ApproveProposalRequest, EditProposalRequest, GenerateProposalsRequest, ) from photo_pipeline.services.albums import AlbumService from photo_pipeline.services.proposals import ConflictError, ProposalError, ProposalService from photo_pipeline.services.rename_journal import RenameJournal router = APIRouter(tags=["albums"]) def _roots(request: Request) -> tuple: return tuple(request.app.state.config.library_roots) def _albums(request: Request) -> AlbumService: return AlbumService(request.app.state.session_factory, library_roots=_roots(request)) def _proposals(request: Request) -> ProposalService: return ProposalService(request.app.state.session_factory, library_roots=_roots(request)) def _error(status: int, code: str, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}}) def _unresolved_rename(request: Request) -> JSONResponse | None: """Refuse work that feeds renaming while the library may be half-renamed. An unresolved rename means some folder paths on disk disagree with the database, so evidence gathered now — and any approval based on it — could describe a location that no longer exists. Reading stays available; only mutation stops. """ if RenameJournal(request.app.state.session_factory).blocks_mutation(): return _error( 409, "rename_recovery_required", "an unresolved rename is in progress; resolve it before changing album names", ) return None @router.get("/albums/evidence") def evidence( request: Request, offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=500), ) -> dict: return _albums(request).aggregate_evidence(offset=offset, limit=limit) @router.post("/albums/proposals") def generate(body: GenerateProposalsRequest, request: Request): return _unresolved_rename(request) or _proposals(request).generate(body.albums) @router.get("/albums/proposals") def list_proposals(request: Request, status: str | None = None) -> dict: return _proposals(request).list(status=status) @router.get("/albums/proposals/{album:path}") def get_proposal(album: str, request: Request): data = _proposals(request).get(album) if data is None: return _error(404, "not_found", f"no proposal for album {album!r}") return data @router.post("/albums/proposals/{album:path}/edit") def edit_proposal(album: str, body: EditProposalRequest, request: Request): try: return _proposals(request).edit(album, body.name, expected_version=body.expected_version) except ConflictError as error: return _error(409, "version_conflict", str(error)) except ProposalError as error: return _error(422, "invalid_proposal", str(error)) @router.post("/albums/proposals/{album:path}/approve") def approve_proposal(album: str, body: ApproveProposalRequest, request: Request): blocked = _unresolved_rename(request) if blocked is not None: return blocked try: return _proposals(request).approve(album, expected_version=body.expected_version) except ConflictError as error: return _error(409, "version_conflict", str(error)) except ProposalError as error: return _error(422, "invalid_proposal", str(error))