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:
curo1305
2026-05-25 18:37:22 +02:00
parent 259a1542d8
commit 33a6f9a290
3 changed files with 540 additions and 6 deletions
+108 -6
View File
@@ -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} ───────────────────────────────────────────────