"""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"}