"""Document CRUD and validation API.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.serializers import document_out from app.db import get_session from app.models import Document from app.schemas import DocStatus, DocumentOut, DocumentUpdate from app.services.cleanup import cleanup_orphan_documents from app.services.paths import resolve_stored_path from app.services.validation import ( apply_duplicate_flags, apply_post_extract_checks, average_from_detail, mark_fields_confirmed, ) router = APIRouter(prefix="/api/documents", tags=["documents"]) @router.get("", response_model=list[DocumentOut]) async def list_documents( status: str | None = None, doc_type: str | None = None, limit: int = Query(100, ge=1, le=500), session: AsyncSession = Depends(get_session), ) -> list[DocumentOut]: """List processed documents (files that still exist).""" stmt = select(Document).order_by(Document.created_at.desc()) if status: stmt = stmt.where(Document.status == status) if doc_type: stmt = stmt.where(Document.doc_type == doc_type) stmt = stmt.limit(limit * 5) result = await session.execute(stmt) docs = [] for d in result.scalars().all(): if len(docs) >= limit: break try: if resolve_stored_path(d.file_path).is_file(): docs.append(document_out(d)) except FileNotFoundError: continue return docs @router.post("/cleanup") async def cleanup_documents( session: AsyncSession = Depends(get_session), ) -> dict[str, int]: """Remove orphan DB rows whose files were deleted.""" return await cleanup_orphan_documents(session) @router.get("/{doc_id}", response_model=DocumentOut) async def get_document( doc_id: str, session: AsyncSession = Depends(get_session), ) -> DocumentOut: """Fetch one document.""" doc = await session.get(Document, doc_id) if not doc: raise HTTPException(404, "Document not found") return document_out(doc) @router.get("/{doc_id}/file") async def get_document_file( doc_id: str, session: AsyncSession = Depends(get_session), ) -> FileResponse: """Serve original uploaded file for validation UI.""" doc = await session.get(Document, doc_id) if not doc: raise HTTPException(404, "Document not found") try: path = resolve_stored_path(doc.file_path) except FileNotFoundError as exc: raise HTTPException( 404, "Original file is no longer available", ) from exc if not path.is_file(): raise HTTPException( 404, "Original file is no longer available", ) return FileResponse( path, filename=doc.filename, media_type=doc.mime_type or "application/octet-stream", content_disposition_type="inline", ) @router.patch("/{doc_id}", response_model=DocumentOut) async def update_document( doc_id: str, body: DocumentUpdate, session: AsyncSession = Depends(get_session), ) -> DocumentOut: """Apply manual corrections and re-run checks.""" doc = await session.get(Document, doc_id) if not doc: raise HTTPException(404, "Document not found") if body.doc_type: doc.doc_type = body.doc_type doc.extracted = body.extracted issues, is_dup, dup_of = await apply_post_extract_checks( session, document_id=doc.id, doc_type=doc.doc_type, data=body.extracted, ) apply_duplicate_flags( doc, is_duplicate=is_dup, duplicate_of=dup_of, ) conf = mark_fields_confirmed(body.extracted, doc.confidence) doc.confidence = conf doc.avg_confidence = average_from_detail(conf) or 1.0 doc.issues = issues doc.status = DocStatus.VALIDATED.value await session.commit() await session.refresh(doc) return document_out(doc)