US04-05: Operate Rename Plans in the Browser

This commit is contained in:
2026-08-16 11:51:18 +02:00
parent 7103cb84bf
commit 9a7810b936
12 changed files with 809 additions and 9 deletions

View File

@@ -17,6 +17,7 @@ from photo_pipeline.schemas import (
)
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"])
@@ -37,6 +38,22 @@ 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,
@@ -47,8 +64,8 @@ def evidence(
@router.post("/albums/proposals")
def generate(body: GenerateProposalsRequest, request: Request) -> dict:
return _proposals(request).generate(body.albums)
def generate(body: GenerateProposalsRequest, request: Request):
return _unresolved_rename(request) or _proposals(request).generate(body.albums)
@router.get("/albums/proposals")
@@ -76,6 +93,9 @@ def edit_proposal(album: str, body: EditProposalRequest, request: Request):
@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:

View File

@@ -229,6 +229,12 @@ class RenameJournal:
classification, reason = _classify(row["journal_state"], source_exists, destination_exists)
return {
"operation_id": operation_id,
"plan_id": row["plan_id"],
"album": row["album"],
# The paths travel with the verdict so a reviewer can see which folder is
# unresolved without correlating an opaque operation id by hand.
"source_path": row["source_path"],
"destination_path": row["destination_path"],
"journal_state": row["journal_state"],
"classification": classification,
"reason": reason,

View File

@@ -418,8 +418,17 @@ def _plan_dict(plan: RenamePlan, operations: list[RenameOperation]) -> dict:
"asset_count": operation.asset_count,
"same_filesystem": operation.same_filesystem,
"case_only": operation.case_only,
"issues": json.loads(operation.issues or "[]"),
# Severity is decided here, not in the browser: a client that has to
# keep its own copy of BLOCKING_CODES will eventually disagree with
# the validator about whether a plan can run.
"issues": [
{**issue, "blocking": issue["code"] in BLOCKING_CODES}
for issue in json.loads(operation.issues or "[]")
],
"journal_state": operation.journal_state,
"verified_at": operation.verified_at.isoformat() if operation.verified_at else None,
"error_code": operation.error_code,
"error_message": operation.error_message,
}
for operation in operations
],