feat(phase-4): Folders API (FOLD-01..05), audit helper (flush-not-commit), document sort/FTS/move
- backend/api/folders.py: POST /api/folders (create), GET /api/folders (list),
GET /api/folders/{id} (breadcrumb), PATCH /api/folders/{id} (rename),
DELETE /api/folders/{id} (cascade-delete + atomic quota decrement),
PATCH /api/documents/{id}/folder (move document)
- All folder endpoints use get_regular_user (admin gets 403); 404 for IDOR
- IntegrityError caught -> 409 on duplicate folder name under same parent
- WITH RECURSIVE CTE for subtree collection with SQLite fallback (OperationalError)
- Atomic quota decrement with CASE WHEN pattern (SQLite compat)
- MinIO object deletion best-effort (per-object try/except)
- write_audit_log called after folder.created, folder.renamed, folder.deleted
- backend/api/documents.py: add sort, order, folder_id, q params to list_documents;
add is_shared field to each document in response using Share subquery
- backend/main.py: register folders_router and document_move_router
This commit is contained in:
+108
-6
@@ -22,10 +22,10 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select, text, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Quota, User
|
||||
from db.models import Document, Quota, Share, User
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from services import classifier, storage
|
||||
@@ -190,18 +190,120 @@ async def list_documents(
|
||||
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, optionally filtered by topic.
|
||||
"""List documents with optional sort, folder filter, and full-text search.
|
||||
|
||||
D-16: requires authenticated regular user (get_regular_user rejects admins).
|
||||
Returns only documents belonging to the current user.
|
||||
|
||||
FOLD-05: sort by name|date|size; order asc|desc; folder_id filter;
|
||||
q full-text search via plainto_tsquery (PostgreSQL only — silently skipped
|
||||
on SQLite when function is unavailable). FTS scope is always scoped to
|
||||
current_user.id (T-04-03-02).
|
||||
|
||||
Backward-compat: when sort/order/folder_id/q are not provided, behaviour
|
||||
is identical to the pre-Phase-4 implementation.
|
||||
"""
|
||||
docs = await storage.list_metadata(session, user_id=current_user.id, topic=topic)
|
||||
total = len(docs)
|
||||
# If no new params used, fall through to the legacy storage.list_metadata path
|
||||
# to preserve full backward compatibility with topic filtering.
|
||||
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
|
||||
# Add is_shared field (Phase 4 addition)
|
||||
shared_result = await session.execute(
|
||||
select(Share.document_id).where(Share.owner_id == current_user.id)
|
||||
)
|
||||
shared_ids = {row[0] for row in shared_result.fetchall()}
|
||||
items = []
|
||||
for d in docs[start : start + per_page]:
|
||||
doc_id_str = d.get("id", "")
|
||||
try:
|
||||
doc_uuid = uuid.UUID(doc_id_str)
|
||||
except (ValueError, AttributeError):
|
||||
doc_uuid = None
|
||||
d["is_shared"] = doc_uuid in shared_ids if doc_uuid else False
|
||||
items.append(d)
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
# New path: direct ORM query with sort/filter/FTS
|
||||
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
|
||||
|
||||
stmt = select(Document).where(Document.user_id == current_user.id)
|
||||
|
||||
# Topic filter (join-based, same as list_metadata)
|
||||
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)
|
||||
)
|
||||
|
||||
# Folder filter
|
||||
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)
|
||||
|
||||
# Full-text search — plainto_tsquery on extracted_text (PostgreSQL only)
|
||||
# Wrapped in try/except so unit tests on SQLite are not broken (FOLD-05)
|
||||
fts_requested = q is not None and len(q) >= 2
|
||||
if fts_requested:
|
||||
try:
|
||||
stmt = stmt.where(
|
||||
func.to_tsvector("english", func.coalesce(Document.extracted_text, "")).op("@@")(
|
||||
func.plainto_tsquery("english", q)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass # FTS not available (e.g. SQLite) — return unfiltered results
|
||||
|
||||
# Sort
|
||||
sort_col = Document.created_at # default: date
|
||||
if sort == "name":
|
||||
sort_col = Document.filename
|
||||
elif sort == "size":
|
||||
sort_col = Document.size_bytes
|
||||
|
||||
if order == "asc":
|
||||
stmt = stmt.order_by(sort_col.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(sort_col.desc())
|
||||
|
||||
result = await session.execute(stmt)
|
||||
docs_orm = result.scalars().all()
|
||||
|
||||
# is_shared subquery
|
||||
shared_result = await session.execute(
|
||||
select(Share.document_id).where(Share.owner_id == current_user.id)
|
||||
)
|
||||
shared_ids = {row[0] for row in shared_result.fetchall()}
|
||||
|
||||
# Serialize
|
||||
all_items = []
|
||||
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": docs[start : start + per_page], "total": total, "page": page, "per_page": per_page}
|
||||
return {
|
||||
"items": all_items[start : start + per_page],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/documents/{doc_id} ───────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user