Rescue Paperjet v1 implementation

This commit is contained in:
Elijah 2026-08-14 21:05:22 -07:00
parent 7ff5c8130d
commit db9e2ca51b
83 changed files with 6186 additions and 3894 deletions

View file

@ -1,28 +1,31 @@
"""Document management endpoints."""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.config import settings
from app.db import get_db
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.version import Version
from app.schemas.document import (
DocumentListResponse, DocumentMeta, BulkDeleteRequest,
BulkRestoreRequest, DocumentUpdateRequest
BulkDeleteRequest,
BulkRestoreRequest,
DocumentListResponse,
DocumentMeta,
DocumentUpdateRequest,
)
from app.services.storage import save_upload_file, delete_pdf_file
from app.services.thumbnails import generate_thumbnail, delete_thumbnail
from app.config import settings
from app.services.assets import delete_asset_directory
from app.services.storage import delete_pdf_file, safe_filename, save_upload_file
from app.services.thumbnails import delete_thumbnail, generate_thumbnail
router = APIRouter(
prefix="/documents",
tags=["documents"],
dependencies=[Depends(get_current_user)]
prefix="/documents", tags=["documents"], dependencies=[Depends(get_current_user)]
)
@ -31,109 +34,135 @@ def list_documents(
query: str = "",
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
db: Session = Depends(get_db),
) -> DocumentListResponse:
"""List non-trashed documents."""
q = db.query(Document).filter(Document.deleted_at.is_(None))
if query:
q = q.filter(Document.title.ilike(f"%{query}%"))
total = q.count()
items = q.order_by(desc(Document.updated_at)).offset(skip).limit(limit).all()
return DocumentListResponse(items=items, total=total)
return DocumentListResponse(
items=[DocumentMeta.model_validate(item) for item in items], total=total
)
@router.get("/trash", response_model=DocumentListResponse)
def list_trash(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
db: Session = Depends(get_db),
) -> DocumentListResponse:
"""List trashed documents."""
q = db.query(Document).filter(Document.deleted_at.is_not(None))
total = q.count()
items = q.order_by(desc(Document.deleted_at)).offset(skip).limit(limit).all()
return DocumentListResponse(items=items, total=total)
return DocumentListResponse(
items=[DocumentMeta.model_validate(item) for item in items], total=total
)
@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
@router.post(
"",
dependencies=[Depends(verify_csrf)],
response_model=DocumentMeta,
status_code=status.HTTP_201_CREATED,
)
async def upload_document(
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
file: UploadFile = File(...), db: Session = Depends(get_db)
) -> DocumentMeta:
"""Upload a new PDF document."""
# Note: Starlette/FastAPI loads file into memory/spooled file.
# To truly enforce max size before reading we rely on Nginx and logic in storage.py.
# But checking size if available:
if file.size and file.size > settings.MAX_UPLOAD_MB * 1024 * 1024:
raise HTTPException(status_code=413, detail="File too large")
file_id, size_bytes, page_count = await save_upload_file(file)
# Generate thumbnail synchronously
generate_thumbnail(file_id)
now = datetime.now(timezone.utc).isoformat()
thumbnail_generated = generate_thumbnail(file_id)
now = datetime.now(UTC).isoformat()
filename = safe_filename(file.filename)
doc = Document(
id=file_id,
title=file.filename or "Untitled",
original_filename=file.filename or "Untitled.pdf",
title=filename,
original_filename=filename,
file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"),
thumbnail_path=(
str(settings.THUMBNAILS_PATH / f"{file_id}.png") if thumbnail_generated else None
),
page_count=page_count,
size_bytes=size_bytes,
created_at=now,
updated_at=now
updated_at=now,
)
db.add(doc)
db.commit()
try:
db.add(doc)
db.commit()
except Exception:
delete_pdf_file(file_id)
delete_thumbnail(file_id)
delete_asset_directory(file_id)
raise
db.refresh(doc)
return doc
return DocumentMeta.model_validate(doc)
@router.get("/{id}", response_model=DocumentMeta)
def get_document(id: str, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == id).first()
def get_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
doc = db.query(Document).filter(Document.id == id, Document.deleted_at.is_(None)).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
return DocumentMeta.model_validate(doc)
@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def update_document(id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)):
def update_document(
id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)
) -> DocumentMeta:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if update.title is not None:
doc.title = update.title
title = update.title.strip()
if not title:
raise HTTPException(status_code=422, detail="Title cannot be empty")
doc.title = title
if update.in_trash is not None:
if update.in_trash:
doc.deleted_at = datetime.now(timezone.utc).isoformat()
doc.deleted_at = datetime.now(UTC).isoformat()
else:
doc.deleted_at = None
doc.updated_at = datetime.now(timezone.utc).isoformat()
doc.updated_at = datetime.now(UTC).isoformat()
db.commit()
db.refresh(doc)
return doc
return DocumentMeta.model_validate(doc)
@router.delete("/{id}", dependencies=[Depends(verify_csrf)])
def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)):
def delete_document(
id: str, permanent: bool = False, db: Session = Depends(get_db)
) -> dict[str, str]:
"""Soft delete by default. Hard delete if permanent=True AND already in trash."""
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if permanent:
if doc.deleted_at is None:
raise HTTPException(status_code=400, detail="Must be in trash to delete permanently")
# Hard delete
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
@ -141,52 +170,57 @@ def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_
return {"status": "deleted"}
else:
# Soft delete
doc.deleted_at = datetime.now(timezone.utc).isoformat()
doc.deleted_at = datetime.now(UTC).isoformat()
doc.updated_at = doc.deleted_at
db.commit()
return {"status": "trashed"}
@router.post("/{id}/restore", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def restore_document(id: str, db: Session = Depends(get_db)):
def restore_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
doc.deleted_at = None
doc.updated_at = datetime.now(timezone.utc).isoformat()
doc.updated_at = datetime.now(UTC).isoformat()
db.commit()
db.refresh(doc)
return doc
return DocumentMeta.model_validate(doc)
@router.post("/bulk-delete", dependencies=[Depends(verify_csrf)])
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc).isoformat()
db.query(Document).filter(Document.id.in_(data.ids)).update({
Document.deleted_at: now
}, synchronize_session=False)
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
now = datetime.now(UTC).isoformat()
count = (
db.query(Document)
.filter(Document.id.in_(data.ids), Document.deleted_at.is_(None))
.update({Document.deleted_at: now}, synchronize_session=False)
)
db.commit()
return {"status": "ok", "count": len(data.ids)}
return {"status": "ok", "count": count}
@router.post("/bulk-restore", dependencies=[Depends(verify_csrf)])
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc).isoformat()
db.query(Document).filter(Document.id.in_(data.ids)).update({
Document.deleted_at: None,
Document.updated_at: now
}, synchronize_session=False)
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
now = datetime.now(UTC).isoformat()
count = (
db.query(Document)
.filter(Document.id.in_(data.ids), Document.deleted_at.is_not(None))
.update({Document.deleted_at: None, Document.updated_at: now}, synchronize_session=False)
)
db.commit()
return {"status": "ok", "count": len(data.ids)}
return {"status": "ok", "count": count}
@router.post("/trash/empty", dependencies=[Depends(verify_csrf)])
def empty_trash(db: Session = Depends(get_db)):
def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]:
docs = db.query(Document).filter(Document.deleted_at.is_not(None)).all()
count = 0
for doc in docs:
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
@ -196,26 +230,26 @@ def empty_trash(db: Session = Depends(get_db)):
@router.get("/{id}/file")
def get_document_file(id: str, db: Session = Depends(get_db)):
def get_document_file(id: str, db: Session = Depends(get_db)) -> FileResponse:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
path = settings.PDF_STORAGE_PATH / f"{doc.id}.pdf"
if not path.exists():
raise HTTPException(status_code=404, detail="File missing")
return FileResponse(path, media_type="application/pdf", filename=doc.filename)
@router.get("/{id}/thumbnail")
def get_document_thumbnail(id: str, db: Session = Depends(get_db)):
def get_document_thumbnail(id: str, db: Session = Depends(get_db)) -> FileResponse:
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
path = settings.THUMBNAILS_PATH / f"{doc.id}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Thumbnail missing")
return FileResponse(path, media_type="image/png")