"""Annotation checkpoint and restore endpoints.""" import json from datetime import UTC, datetime from typing import Literal, cast from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import desc, select from sqlalchemy.orm import Session from app.auth.dependencies import get_current_user, verify_csrf from app.db import get_db from app.models.annotation_state import AnnotationState from app.models.document import Document from app.models.version import Version from app.schemas.annotations import AnnotationPayload from app.schemas.versions import ( VersionCreateRequest, VersionDataResponse, VersionListResponse, VersionMeta, VersionRestoreResponse, ) router = APIRouter( prefix="/documents", tags=["versions"], dependencies=[Depends(get_current_user)], ) def _now_iso() -> str: return datetime.now(UTC).isoformat() def _document_or_404(document_id: str, db: Session) -> Document: document = db.scalar( select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)) ) if not document: raise HTTPException(status_code=404, detail="Document not found") return document def _read_state(document_id: str, db: Session) -> list[AnnotationPayload]: state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) if not state: return [] try: data = json.loads(state.data) except json.JSONDecodeError: return [] return data if isinstance(data, list) else [] def _version_meta(version: Version) -> VersionMeta: try: data = json.loads(version.data) except json.JSONDecodeError: data = [] return VersionMeta( id=version.id, documentId=version.document_id, label=version.label, kind=cast( Literal["manual", "auto"], version.kind if version.kind in {"manual", "auto"} else "auto", ), createdAt=datetime.fromisoformat(version.created_at), annotationCount=len(data) if isinstance(data, list) else 0, ) def _create_snapshot( document_id: str, db: Session, *, label: str | None, kind: str, ) -> Version: version = Version( document_id=document_id, label=label, kind=kind, data=json.dumps(_read_state(document_id, db), separators=(",", ":")), ) db.add(version) db.flush() return version @router.get("/{document_id}/versions", response_model=VersionListResponse) def list_versions(document_id: str, db: Session = Depends(get_db)) -> VersionListResponse: _document_or_404(document_id, db) versions = db.scalars( select(Version).where(Version.document_id == document_id).order_by(desc(Version.created_at)) ).all() return VersionListResponse(items=[_version_meta(version) for version in versions]) @router.post( "/{document_id}/versions", response_model=VersionMeta, status_code=status.HTTP_201_CREATED, dependencies=[Depends(verify_csrf)], ) def create_version( document_id: str, request: VersionCreateRequest, db: Session = Depends(get_db), ) -> VersionMeta: _document_or_404(document_id, db) version = _create_snapshot( document_id, db, label=request.label.strip() if request.label and request.label.strip() else None, kind=request.kind, ) db.commit() return _version_meta(version) @router.get("/{document_id}/versions/{version_id}", response_model=VersionDataResponse) def get_version( document_id: str, version_id: str, db: Session = Depends(get_db), ) -> VersionDataResponse: _document_or_404(document_id, db) version = db.scalar( select(Version).where(Version.id == version_id, Version.document_id == document_id) ) if not version: raise HTTPException(status_code=404, detail="Version not found") try: data = json.loads(version.data) except json.JSONDecodeError: data = [] return VersionDataResponse( data=data if isinstance(data, list) else [], meta=_version_meta(version), ) @router.post( "/{document_id}/versions/{version_id}/restore", response_model=VersionRestoreResponse, dependencies=[Depends(verify_csrf)], ) def restore_version( document_id: str, version_id: str, db: Session = Depends(get_db), ) -> VersionRestoreResponse: document = _document_or_404(document_id, db) target = db.scalar( select(Version).where(Version.id == version_id, Version.document_id == document_id) ) if not target: raise HTTPException(status_code=404, detail="Version not found") # Restoring is destructive to the current working layer, so make it undoable. _create_snapshot(document_id, db, label="Before restore", kind="auto") try: restored_data = json.loads(target.data) except json.JSONDecodeError: restored_data = [] if not isinstance(restored_data, list): restored_data = [] state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) now = _now_iso() if state: state.data = json.dumps(restored_data, separators=(",", ":")) state.updated_at = now else: db.add( AnnotationState( document_id=document_id, data=json.dumps(restored_data, separators=(",", ":")), updated_at=now, ) ) document.updated_at = now db.commit() return VersionRestoreResponse(updatedAt=datetime.fromisoformat(now)) @router.delete( "/{document_id}/versions/{version_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(verify_csrf)], ) def delete_version(document_id: str, version_id: str, db: Session = Depends(get_db)) -> Response: _document_or_404(document_id, db) version = db.scalar( select(Version).where(Version.id == version_id, Version.document_id == document_id) ) if not version: raise HTTPException(status_code=404, detail="Version not found") db.delete(version) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT)