Files
kite/backend/api/documents/crud.py
T

266 lines
8.6 KiB
Python

"""Document CRUD endpoints."""
from __future__ import annotations
import uuid
from typing import Optional
import structlog as _structlog
_log = _structlog.get_logger(__name__)
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Folder, Share, User
from deps.auth import get_regular_user
from deps.db import get_db
from deps.utils import get_client_ip
from services import classifier, storage
from services.audit import write_audit_log
from services.rate_limiting import account_limiter
from storage import get_storage_backend_for_document as _get_storage_backend_for_document
from tasks.document_tasks import extract_and_classify
from api.documents.shared import DocumentPatch, get_accessible_document, get_owned_document
router = APIRouter()
async def _shared_document_ids(session: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]:
result = await session.execute(select(Share.document_id).where(Share.owner_id == user_id))
return {row[0] for row in result.fetchall()}
async def _decorate_shared_flags(
session: AsyncSession,
user_id: uuid.UUID,
items: list[dict],
) -> list[dict]:
shared_ids = await _shared_document_ids(session, user_id)
for item in items:
try:
doc_id = uuid.UUID(item.get("id", ""))
except (TypeError, ValueError):
item["is_shared"] = False
else:
item["is_shared"] = doc_id in shared_ids
return items
@account_limiter.limit("100/minute")
async def list_documents(
request: Request,
topic: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
sort: str = Query("date"),
order: str = Query("desc"),
folder_id: Optional[str] = Query(None),
q: Optional[str] = Query(None),
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""List documents with optional sort, folder filter, and full-text search."""
request.state.current_user = current_user
if folder_id is None and q is None and sort == "date" and order == "desc":
docs = await storage.list_metadata(session, user_id=current_user.id, topic=topic)
total = len(docs)
start = (page - 1) * per_page
items = await _decorate_shared_flags(
session,
current_user.id,
docs[start : start + per_page],
)
return {"items": items, "total": total, "page": page, "per_page": per_page}
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
stmt = select(Document).where(Document.user_id == current_user.id)
if topic is not None:
stmt = (
stmt.join(DocumentTopic, DocumentTopic.document_id == Document.id)
.join(Topic, Topic.id == DocumentTopic.topic_id)
.where(Topic.name == topic)
)
if folder_id is not None:
try:
folder_uuid = uuid.UUID(folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
stmt = stmt.where(Document.folder_id == folder_uuid)
sort_col = Document.created_at # default: date
if sort == "name":
sort_col = Document.filename
elif sort == "size":
sort_col = Document.size_bytes
order_fn = sort_col.asc if order == "asc" else sort_col.desc
stmt = stmt.order_by(order_fn())
fts_requested = q is not None and len(q) >= 2
if fts_requested:
fts_stmt = stmt.where(
func.to_tsvector("english", func.coalesce(Document.extracted_text, "")).op("@@")(
func.plainto_tsquery("english", q)
)
)
try:
result = await session.execute(fts_stmt)
except Exception:
result = await session.execute(stmt)
else:
result = await session.execute(stmt)
docs_orm = result.scalars().all()
all_items = []
shared_ids = await _shared_document_ids(session, current_user.id)
for doc in docs_orm:
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
topic_names = await _load_topic_names(session, doc.id)
d = _doc_to_dict(doc, topic_names)
d["is_shared"] = doc.id in shared_ids
all_items.append(d)
total = len(all_items)
start = (page - 1) * per_page
return {
"items": all_items[start : start + per_page],
"total": total,
"page": page,
"per_page": per_page,
}
@router.get("/{doc_id}")
@account_limiter.limit("100/minute")
async def get_document(
request: Request,
doc_id: str,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Return document metadata by ID."""
request.state.current_user = current_user
_, is_recipient = await get_accessible_document(session, doc_id, current_user.id)
meta = await storage.get_metadata(session, doc_id)
if meta is None:
raise HTTPException(404, "Document not found")
if is_recipient:
meta.pop("extracted_text", None)
return meta
@router.patch("/{doc_id}")
@account_limiter.limit("100/minute")
async def patch_document(
request: Request,
doc_id: str,
body: DocumentPatch,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Update document metadata."""
request.state.current_user = current_user
doc = await get_owned_document(session, doc_id, current_user.id)
if not body.model_fields_set:
raise HTTPException(422, "At least one field (filename, folder_id) must be provided")
if "filename" in body.model_fields_set and body.filename is not None:
doc.filename = body.filename
if "folder_id" in body.model_fields_set:
if body.folder_id is not None:
target = await session.get(Folder, body.folder_id)
if target is None or target.user_id != current_user.id:
raise HTTPException(404, "Folder not found")
doc.folder_id = body.folder_id
await session.commit()
meta = await storage.get_metadata(session, doc_id)
if meta is None:
raise HTTPException(404, "Document not found")
return meta
@router.delete("/{doc_id}")
@account_limiter.limit("100/minute")
async def delete_document(
doc_id: str,
request: Request,
remove_only: bool = Query(default=False),
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Delete a document and decrement quota when appropriate."""
request.state.current_user = current_user
doc = await get_owned_document(session, doc_id, current_user.id)
is_cloud = doc.storage_backend != "minio"
_doc_size = doc.size_bytes
_doc_id = doc.id
_ip = get_client_ip(request)
if is_cloud and not remove_only:
try:
import api.documents as _doc_pkg
_gsb = _doc_pkg.get_storage_backend_for_document
cloud_backend = await _gsb(doc, current_user, session)
await cloud_backend.delete_object(doc.object_key)
except Exception as exc:
_log.warning("cloud_delete_failed", provider=doc.storage_backend, error=str(exc))
return JSONResponse(
status_code=200,
content={
"success": False,
"cloud_delete_failed": True,
"detail": "Cloud provider delete failed. You can remove from app only.",
},
)
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False)
if not ok:
raise HTTPException(404, "Document not found")
await write_audit_log(
session,
event_type="document.deleted",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=_doc_id,
ip_address=_ip,
metadata_={"size_bytes": _doc_size},
)
await session.commit()
return {"success": True}
@router.post("/{doc_id}/classify")
@account_limiter.limit("100/minute")
async def classify_document(
request: Request,
doc_id: str,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Re-queue a document for classification via Celery."""
request.state.current_user = current_user
doc = await get_owned_document(session, doc_id, current_user.id)
doc.status = "processing"
await session.commit()
extract_and_classify.delay(str(doc.id))
return {"document_id": str(doc.id), "status": "processing"}