Paperjet/backend/app/api/v1/versions.py

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)