Pagewise / app /api /documents.py
nvikou's picture
Initial commit
42fd0d2 verified
Raw History Blame Contribute Delete
4.99 kB
"""Document upload, list, delete, and file serving."""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
UploadFile,
)
from fastapi.responses import FileResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import get_session
from app.models import Document
from app.schemas import DocumentOut, FolderOut, IngestResponse
from app.services.rag import (
SUPPORTED_SUFFIXES,
document_file_path,
ingest_file,
)
from app.services.store import get_document_store
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["documents"])
@router.get("", response_model=list[DocumentOut])
async def list_documents(
folder: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Document]:
"""List indexed documents."""
stmt = select(Document).order_by(Document.created_at.desc())
if folder:
stmt = stmt.where(Document.folder == folder)
result = await session.execute(stmt)
return list(result.scalars().all())
@router.get("/folders", response_model=list[FolderOut])
async def list_folders(
session: AsyncSession = Depends(get_session),
) -> list[FolderOut]:
"""List folders with document counts."""
result = await session.execute(
select(Document.folder, func.count(Document.id))
.group_by(Document.folder)
.order_by(Document.folder)
)
return [
FolderOut(name=row[0], document_count=row[1])
for row in result.all()
]
@router.get("/{document_id}", response_model=DocumentOut)
async def get_document(
document_id: str,
session: AsyncSession = Depends(get_session),
) -> Document:
"""Fetch one document metadata row."""
doc = await session.get(Document, document_id)
if doc is None:
raise HTTPException(404, "Document not found")
return doc
@router.get("/{document_id}/file")
async def download_document(
document_id: str,
session: AsyncSession = Depends(get_session),
) -> FileResponse:
"""Serve the original file for the PDF viewer."""
path = await document_file_path(session, document_id)
if path is None or not path.exists():
raise HTTPException(404, "File not found")
doc = await session.get(Document, document_id)
media = doc.media_type if doc else "application/octet-stream"
return FileResponse(
path,
media_type=media,
filename=doc.original_name if doc else path.name,
)
@router.post("/upload", response_model=IngestResponse)
async def upload_document(
file: UploadFile = File(...),
folder: str = Form("general"),
session: AsyncSession = Depends(get_session),
) -> IngestResponse:
"""Upload and index a document."""
settings = get_settings()
name = file.filename or "upload.bin"
suffix = Path(name).suffix.lower()
if suffix not in SUPPORTED_SUFFIXES:
raise HTTPException(
400,
f"Unsupported type {suffix}. "
f"Allowed: {', '.join(sorted(SUPPORTED_SUFFIXES))}",
)
raw = await file.read()
max_bytes = settings.max_upload_mb * 1024 * 1024
if len(raw) > max_bytes:
raise HTTPException(
400,
f"File exceeds {settings.max_upload_mb} MB limit",
)
tmp_dir = Path(settings.upload_path) / "_tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_path = tmp_dir / name
tmp_path.write_bytes(raw)
try:
doc, chunks = await ingest_file(
session,
source_path=tmp_path,
original_name=name,
folder=(folder or "general").strip() or "general",
media_type=file.content_type
or "application/octet-stream",
)
finally:
if tmp_path.exists():
tmp_path.unlink()
return IngestResponse(
document=DocumentOut.model_validate(doc),
chunks_added=chunks,
)
@router.delete("/{document_id}")
async def delete_document(
document_id: str,
session: AsyncSession = Depends(get_session),
) -> dict[str, str]:
"""Delete document metadata, chunks, and file."""
doc = await session.get(Document, document_id)
if doc is None:
raise HTTPException(404, "Document not found")
store = get_document_store()
store.delete_by_document(document_id)
settings = get_settings()
folder = Path(settings.upload_path) / document_id
if folder.exists():
for child in folder.iterdir():
child.unlink(missing_ok=True)
folder.rmdir()
await session.delete(doc)
await session.commit()
return {"status": "deleted", "id": document_id}