Files
photoanalyzer/photo_pipeline/api/routes/duplicates.py

80 lines
2.5 KiB
Python

"""Duplicate cluster review API: list, detail, and decision.
Decisions carry an ``expected_version``; a stale version returns 409 without
mutating anything, so a browser acting on out-of-date state cannot overwrite a
newer decision.
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from photo_pipeline.schemas import DecisionRequest
from photo_pipeline.services.duplicates import (
MAX_MEMBER_PAGE,
MEMBER_PAGE,
ConflictError,
DuplicateError,
DuplicateService,
)
router = APIRouter(tags=["duplicates"])
def _service(request: Request) -> DuplicateService:
return DuplicateService(request.app.state.session_factory)
def _error(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
@router.post("/duplicates/detect")
def detect(request: Request) -> dict:
"""Run duplicate detection over the current assets (synchronous in Phase A)."""
report = _service(request).detect()
return {"clusters": len(report.clusters), "counts": report.counts}
@router.get("/duplicates/clusters")
def list_clusters(
request: Request,
state: str | None = None,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> dict:
return _service(request).list_clusters(state=state, limit=limit, offset=offset)
@router.get("/duplicates/clusters/{cluster_id}")
def get_cluster(
cluster_id: str,
request: Request,
limit: int = Query(MEMBER_PAGE, ge=1, le=MAX_MEMBER_PAGE),
offset: int = Query(0, ge=0),
):
detail = _service(request).get_cluster(cluster_id, limit=limit, offset=offset)
if detail is None:
return _error(404, "not_found", f"unknown cluster {cluster_id}")
return detail
@router.post("/duplicates/clusters/{cluster_id}/decision")
def decide(cluster_id: str, body: DecisionRequest, request: Request):
service = _service(request)
if service.get_cluster(cluster_id) is None:
return _error(404, "not_found", f"unknown cluster {cluster_id}")
try:
return service.decide(
cluster_id,
body.decision,
canonical_asset_id=body.canonical_asset_id,
expected_version=body.expected_version,
reason=body.reason,
)
except ConflictError as error:
return _error(409, "version_conflict", str(error))
except DuplicateError as error:
return _error(422, "invalid_decision", str(error))