203 lines
6.1 KiB
Python
203 lines
6.1 KiB
Python
"""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)
|