"""Document management endpoints.""" from datetime import UTC, datetime from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import FileResponse from sqlalchemy import desc 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.annotation_state import AnnotationState from app.models.document import Document from app.models.version import Version from app.schemas.document import ( BulkDeleteRequest, BulkRestoreRequest, DocumentListResponse, DocumentMeta, DocumentUpdateRequest, ) 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)] ) @router.get("", response_model=DocumentListResponse) def list_documents( query: str = "", skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), 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=[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), ) -> 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=[DocumentMeta.model_validate(item) for item in items], total=total ) @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) ) -> 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 thumbnail_generated = generate_thumbnail(file_id) now = datetime.now(UTC).isoformat() filename = safe_filename(file.filename) doc = Document( id=file_id, 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, ) 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 DocumentMeta.model_validate(doc) @router.get("/{id}", response_model=DocumentMeta) 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 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) ) -> 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: 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(UTC).isoformat() else: doc.deleted_at = None doc.updated_at = datetime.now(UTC).isoformat() db.commit() db.refresh(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) ) -> 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) db.commit() return {"status": "deleted"} else: # Soft delete 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)) -> 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(UTC).isoformat() db.commit() db.refresh(doc) return DocumentMeta.model_validate(doc) @router.post("/bulk-delete", dependencies=[Depends(verify_csrf)]) 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": count} @router.post("/bulk-restore", dependencies=[Depends(verify_csrf)]) 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": count} @router.post("/trash/empty", dependencies=[Depends(verify_csrf)]) 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) count += 1 db.commit() return {"status": "ok", "deleted": count} @router.get("/{id}/file") 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)) -> 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")