Refactor backend and frontend cleanup paths
This commit is contained in:
+90
-199
@@ -1,21 +1,4 @@
|
||||
"""
|
||||
Folder API endpoints for DocuVault — Phase 4, Plan 03.
|
||||
|
||||
Implements FOLD-01 through FOLD-05:
|
||||
POST /api/folders — create folder (FOLD-01)
|
||||
GET /api/folders — list top-level folders (FOLD-02)
|
||||
GET /api/folders/{id} — get folder + breadcrumb (FOLD-02)
|
||||
PATCH /api/folders/{id} — rename folder (FOLD-03)
|
||||
DELETE /api/folders/{id} — delete folder (cascade) (FOLD-03)
|
||||
PATCH /api/documents/{id}/folder — move document to folder (FOLD-04)
|
||||
|
||||
Security invariants (all enforced):
|
||||
T-04-03-01: get_regular_user on all endpoints (admin gets 403)
|
||||
T-04-03-04: All folder IDOR paths return 404 not 403
|
||||
T-04-03-05: PATCH /api/documents/{id}/folder validates both doc and target folder ownership
|
||||
T-04-03-06: IntegrityError (duplicate folder name) → 409 Conflict
|
||||
T-04-03-03: Atomic quota decrement with CASE WHEN pattern (SQLite compat)
|
||||
"""
|
||||
"""Folder and document-folder organization endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -23,11 +6,11 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, text, func
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Folder, Quota, Share, User
|
||||
from db.models import Document, Folder, User
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
@@ -37,8 +20,6 @@ from storage import get_storage_backend
|
||||
router = APIRouter(prefix="/api/folders", tags=["folders"])
|
||||
|
||||
|
||||
# ── Request / response models ─────────────────────────────────────────────────
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: Optional[str] = None
|
||||
@@ -52,9 +33,6 @@ class DocumentMove(BaseModel):
|
||||
folder_id: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
# ── Helper: folder serialization ──────────────────────────────────────────────
|
||||
|
||||
def _folder_to_dict(folder: Folder) -> dict:
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
@@ -65,8 +43,6 @@ def _folder_to_dict(folder: Folder) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── Helper: document serialization ────────────────────────────────────────────
|
||||
|
||||
def _doc_to_dict(doc: Document) -> dict:
|
||||
return {
|
||||
"id": str(doc.id),
|
||||
@@ -81,7 +57,67 @@ def _doc_to_dict(doc: Document) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── POST /api/folders ─────────────────────────────────────────────────────────
|
||||
def _parse_uuid(value: str, not_found_detail: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=not_found_detail)
|
||||
|
||||
|
||||
async def _get_owned_folder(
|
||||
session: AsyncSession,
|
||||
folder_id: str | uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
detail: str = "Folder not found",
|
||||
) -> Folder:
|
||||
uid = folder_id if isinstance(folder_id, uuid.UUID) else _parse_uuid(folder_id, detail)
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
return folder
|
||||
|
||||
|
||||
async def _get_owned_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = _parse_uuid(doc_id, "Document not found")
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return doc
|
||||
|
||||
|
||||
async def _ensure_unique_folder_name(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
parent_id: Optional[uuid.UUID],
|
||||
exclude_id: Optional[uuid.UUID] = None,
|
||||
) -> None:
|
||||
stmt = select(Folder).where(
|
||||
Folder.user_id == user_id,
|
||||
Folder.name == name,
|
||||
Folder.parent_id == parent_id,
|
||||
)
|
||||
if exclude_id is not None:
|
||||
stmt = stmt.where(Folder.id != exclude_id)
|
||||
|
||||
dup = await session.execute(stmt)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
|
||||
def _duplicate_folder_error() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(
|
||||
@@ -90,35 +126,14 @@ async def create_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Create a new folder for the current user.
|
||||
|
||||
FOLD-01: parent_id (if given) must belong to current_user — 404 otherwise.
|
||||
Duplicate name under same parent returns 409 (T-04-03-06).
|
||||
"""
|
||||
parent_uuid: Optional[uuid.UUID] = None
|
||||
if body.parent_id is not None:
|
||||
try:
|
||||
parent_uuid = uuid.UUID(body.parent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await session.get(Folder, parent_uuid)
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await _get_owned_folder(
|
||||
session, body.parent_id, current_user.id, "Parent folder not found"
|
||||
)
|
||||
parent_uuid = parent.id
|
||||
|
||||
# Explicit duplicate check — UniqueConstraint won't fire when parent_id IS NULL
|
||||
# because SQL treats NULL as distinct from NULL in unique indexes.
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == parent_uuid,
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
await _ensure_unique_folder_name(session, current_user.id, body.name, parent_uuid)
|
||||
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
@@ -130,10 +145,7 @@ async def create_folder(
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
raise _duplicate_folder_error()
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -148,34 +160,20 @@ async def create_folder(
|
||||
return _folder_to_dict(folder)
|
||||
|
||||
|
||||
# ── GET /api/folders ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_folders(
|
||||
parent_id: Optional[str] = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""List the current user's folders at a given level.
|
||||
|
||||
FOLD-02: when parent_id is omitted, returns root folders (parent_id IS NULL).
|
||||
When parent_id is supplied, returns that folder's direct children (asserts ownership).
|
||||
Each folder includes has_children so the frontend can hide expand arrows on leaf nodes.
|
||||
"""
|
||||
parent_uuid: Optional[uuid.UUID] = None
|
||||
if parent_id is not None:
|
||||
try:
|
||||
parent_uuid = uuid.UUID(parent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent_folder = await session.get(Folder, parent_uuid)
|
||||
if parent_folder is None or parent_folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent_folder = await _get_owned_folder(
|
||||
session, parent_id, current_user.id, "Parent folder not found"
|
||||
)
|
||||
parent_uuid = parent_folder.id
|
||||
|
||||
if parent_uuid is None:
|
||||
where_clause = Folder.parent_id.is_(None)
|
||||
else:
|
||||
where_clause = Folder.parent_id == parent_uuid
|
||||
where_clause = Folder.parent_id.is_(None) if parent_uuid is None else Folder.parent_id == parent_uuid
|
||||
|
||||
result = await session.execute(
|
||||
select(Folder)
|
||||
@@ -184,8 +182,6 @@ async def list_folders(
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
|
||||
# One extra query to know which of these folders have sub-folders.
|
||||
# Allows the frontend to hide expand arrows on leaf nodes without extra round-trips.
|
||||
folder_ids = [f.id for f in folders]
|
||||
folders_with_children: set = set()
|
||||
if folder_ids:
|
||||
@@ -205,55 +201,34 @@ async def list_folders(
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/folders/{folder_id} ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{folder_id}")
|
||||
async def get_folder(
|
||||
folder_id: str,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Get folder metadata + breadcrumb array from root to this folder.
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
|
||||
FOLD-02 / FOLD-05: breadcrumb is built via iterative parent-walk in Python
|
||||
(not WITH RECURSIVE) so it is compatible with both PostgreSQL and SQLite tests.
|
||||
|
||||
Response: {id, name, parent_id, user_id, created_at, breadcrumb: [{id, name}, ...]}
|
||||
The breadcrumb array is ordered root-first (root is breadcrumb[0]).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
# Build breadcrumb by walking up the parent chain iteratively.
|
||||
# Ownership check on each ancestor ensures no cross-user traversal.
|
||||
crumbs = [{"id": str(folder.id), "name": folder.name}]
|
||||
current = folder
|
||||
visited: set = {current.id}
|
||||
while current.parent_id is not None:
|
||||
if current.parent_id in visited:
|
||||
break # cycle guard (should not occur with proper constraints)
|
||||
break
|
||||
parent = await session.get(Folder, current.parent_id)
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
break # stop traversal if parent is inaccessible
|
||||
break
|
||||
visited.add(parent.id)
|
||||
crumbs.append({"id": str(parent.id), "name": parent.name})
|
||||
current = parent
|
||||
|
||||
crumbs.reverse() # root-first order
|
||||
crumbs.reverse()
|
||||
|
||||
response = _folder_to_dict(folder)
|
||||
response["breadcrumb"] = crumbs
|
||||
return response
|
||||
|
||||
|
||||
# ── PATCH /api/folders/{folder_id} ───────────────────────────────────────────
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
@@ -262,46 +237,20 @@ async def rename_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Rename a folder.
|
||||
|
||||
FOLD-03: asserts ownership → 404 if not owner.
|
||||
Duplicate name under same parent returns 409 (T-04-03-06).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
old_name = folder.name
|
||||
# Explicit duplicate check — same NULL parent_id issue as create_folder.
|
||||
|
||||
if body.name != folder.name:
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == folder.parent_id,
|
||||
Folder.id != folder.id,
|
||||
)
|
||||
await _ensure_unique_folder_name(
|
||||
session, current_user.id, body.name, folder.parent_id, folder.id
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
folder.name = body.name
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
raise _duplicate_folder_error()
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -316,8 +265,6 @@ async def rename_folder(
|
||||
return _folder_to_dict(folder)
|
||||
|
||||
|
||||
# ── DELETE /api/folders/{folder_id} ──────────────────────────────────────────
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
@@ -325,30 +272,9 @@ async def delete_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Delete a folder and all of its contents (cascade).
|
||||
|
||||
FOLD-03 + D-03:
|
||||
- Collects all documents in the folder subtree using WITH RECURSIVE CTE
|
||||
(wraps in try/except OperationalError for SQLite test compat; fallback
|
||||
uses direct children only).
|
||||
- Sums size_bytes, performs atomic quota decrement (CASE WHEN pattern for
|
||||
SQLite compat — T-04-03-03).
|
||||
- Deletes MinIO objects best-effort (per-object try/except — PATTERNS.md Pattern 2).
|
||||
- Deletes all document rows and the folder row via ORM.
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
folder_name = folder.name
|
||||
|
||||
# Collect all folder IDs in the subtree via WITH RECURSIVE CTE.
|
||||
# Falls back to direct-children-only on SQLite (OperationalError on recursive CTE).
|
||||
subtree_folder_ids: list[str] = []
|
||||
try:
|
||||
cte_result = await session.execute(
|
||||
@@ -361,17 +287,13 @@ async def delete_folder(
|
||||
" WHERE f.user_id = :uid"
|
||||
") SELECT id FROM subtree"
|
||||
),
|
||||
# Use .hex (no dashes) — SQLite stores UUID as 32-char hex; PostgreSQL accepts both.
|
||||
{"root_id": folder.id.hex, "uid": current_user.id.hex},
|
||||
)
|
||||
subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()]
|
||||
except OperationalError:
|
||||
# SQLite fallback: only direct children of this folder
|
||||
subtree_folder_ids = [str(folder.id)]
|
||||
|
||||
# Collect all documents in the subtree folder IDs
|
||||
if subtree_folder_ids:
|
||||
# Build UUID list for IN query
|
||||
subtree_uuids = []
|
||||
for fid in subtree_folder_ids:
|
||||
try:
|
||||
@@ -394,7 +316,6 @@ async def delete_folder(
|
||||
|
||||
total_bytes = sum(d.size_bytes for d in docs)
|
||||
|
||||
# Atomic quota decrement (CASE WHEN for SQLite compat — never goes below 0)
|
||||
if total_bytes > 0:
|
||||
await session.execute(
|
||||
text(
|
||||
@@ -406,20 +327,16 @@ async def delete_folder(
|
||||
{"delta": total_bytes, "uid": current_user.id.hex},
|
||||
)
|
||||
|
||||
# Delete MinIO objects best-effort (per-object, never abort on failure)
|
||||
storage_backend = get_storage_backend()
|
||||
for doc in docs:
|
||||
try:
|
||||
await storage_backend.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # best-effort; stale MinIO objects will be garbage-collected
|
||||
pass
|
||||
|
||||
# Delete document rows
|
||||
for doc in docs:
|
||||
await session.delete(doc)
|
||||
|
||||
# Delete the folder (cascade will handle sub-folders in PostgreSQL;
|
||||
# in SQLite test env we already collected and deleted all documents)
|
||||
await session.delete(folder)
|
||||
await session.commit()
|
||||
|
||||
@@ -428,17 +345,12 @@ async def delete_folder(
|
||||
event_type="folder.deleted",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=uid,
|
||||
resource_id=folder.id,
|
||||
ip_address=get_client_ip(request),
|
||||
metadata_={"name": folder_name, "doc_count": len(docs)},
|
||||
)
|
||||
|
||||
|
||||
# ── PATCH /api/documents/{doc_id}/folder ─────────────────────────────────────
|
||||
# This endpoint lives in the folders router (not documents router) because it
|
||||
# is logically a folder organisation operation. The URL prefix /api/documents
|
||||
# is achieved via an explicit path on this APIRouter. FastAPI supports this.
|
||||
|
||||
document_move_router = APIRouter(prefix="/api/documents", tags=["folders"])
|
||||
|
||||
|
||||
@@ -450,33 +362,12 @@ async def move_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Move a document to a different folder (or to root if folder_id is null).
|
||||
|
||||
FOLD-04:
|
||||
- Asserts document ownership → 404 if not owner.
|
||||
- If folder_id given: asserts target folder ownership → 404 if not owner
|
||||
(T-04-03-05: cross-user folder assignment blocked).
|
||||
- Updates doc.folder_id and commits.
|
||||
- Returns 200 with updated document dict.
|
||||
"""
|
||||
try:
|
||||
doc_uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
doc = await session.get(Document, doc_uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
doc = await _get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
target_folder_uuid: Optional[uuid.UUID] = None
|
||||
if body.folder_id is not None:
|
||||
try:
|
||||
target_folder_uuid = uuid.UUID(body.folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
target_folder = await session.get(Folder, target_folder_uuid)
|
||||
if target_folder is None or target_folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
target_folder = await _get_owned_folder(session, body.folder_id, current_user.id)
|
||||
target_folder_uuid = target_folder.id
|
||||
|
||||
doc.folder_id = target_folder_uuid
|
||||
await session.commit()
|
||||
|
||||
Reference in New Issue
Block a user