SurfSense/surfsense_backend/app/routes/folders_routes.py
CREDO23 56826a63bc refactor(routes): rename to workspace + consolidate URL spellings (Phase 2 Wave E)
Hard cutover of the HTTP surface: collapse the three legacy spellings
(/searchspaces, /search-spaces, /search-space/{id}) onto canonical
/workspaces/{workspace_id}/..., rename the gateway field-setter sub-actions to
singular /workspace, rename search_spaces_routes.py -> workspaces_routes.py and the
search_spaces_router -> workspaces_router include, and flip search_space_id ->
workspace_id in bodies/params. No alias routers: the old URLs now 404 by design.
Full app constructs with zero legacy path spellings.
2026-06-26 18:35:08 +02:00

541 lines
18 KiB
Python

"""API routes for folder CRUD, move, reorder, and document move operations."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.auth.context import AuthContext
from app.db import Document, Folder, Permission, get_async_session
from app.schemas import (
BulkDocumentMove,
DocumentMove,
FolderBreadcrumb,
FolderCreate,
FolderMove,
FolderRead,
FolderReorder,
FolderUpdate,
)
from app.services.folder_service import (
check_no_circular_reference,
generate_folder_position,
get_folder_subtree_ids,
get_subtree_max_depth,
validate_folder_depth,
)
from app.users import get_auth_context
from app.utils.rbac import check_permission
router = APIRouter()
@router.post("/folders", response_model=FolderRead)
async def create_folder(
request: FolderCreate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
user = auth.user
"""Create a new folder. Requires DOCUMENTS_CREATE permission."""
try:
await check_permission(
session,
auth,
request.workspace_id,
Permission.DOCUMENTS_CREATE.value,
"You don't have permission to create folders in this workspace",
)
if request.parent_id is not None:
parent = await session.get(Folder, request.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
if parent.workspace_id != request.workspace_id:
raise HTTPException(
status_code=400,
detail="Parent folder belongs to a different workspace",
)
await validate_folder_depth(session, request.parent_id)
position = await generate_folder_position(
session, request.workspace_id, request.parent_id
)
folder = Folder(
name=request.name,
position=position,
parent_id=request.parent_id,
workspace_id=request.workspace_id,
created_by_id=user.id,
)
session.add(folder)
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
if "uq_folder_space_parent_name" in str(e):
raise HTTPException(
status_code=409,
detail="A folder with this name already exists at this location",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to create folder: {e!s}"
) from e
@router.get("/folders", response_model=list[FolderRead])
async def list_folders(
workspace_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""List all folders in a workspace (flat). Requires DOCUMENTS_READ permission."""
try:
await check_permission(
session,
auth,
workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this workspace",
)
result = await session.execute(
select(Folder)
.where(Folder.workspace_id == workspace_id)
.order_by(Folder.position)
)
return result.scalars().all()
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to list folders: {e!s}"
) from e
@router.get("/folders/{folder_id}", response_model=FolderRead)
async def get_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Get a single folder. Requires DOCUMENTS_READ permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
auth,
folder.workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this workspace",
)
return folder
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to get folder: {e!s}"
) from e
@router.get("/folders/{folder_id}/breadcrumb", response_model=list[FolderBreadcrumb])
async def get_folder_breadcrumb(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Get ancestor chain for breadcrumb display. Requires DOCUMENTS_READ permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
auth,
folder.workspace_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read folders in this workspace",
)
result = await session.execute(
text("""
WITH RECURSIVE ancestors AS (
SELECT id, name, parent_id, 0 AS depth
FROM folders WHERE id = :folder_id
UNION ALL
SELECT f.id, f.name, f.parent_id, a.depth + 1
FROM folders f JOIN ancestors a ON f.id = a.parent_id
)
SELECT id, name FROM ancestors ORDER BY depth DESC;
"""),
{"folder_id": folder_id},
)
rows = result.fetchall()
return [FolderBreadcrumb(id=row.id, name=row.name) for row in rows]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to get breadcrumb: {e!s}"
) from e
@router.patch("/folders/{folder_id}/watched")
async def stop_watching_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Clear the watched flag from a folder's metadata."""
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
auth,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update folders in this workspace",
)
if folder.folder_metadata and isinstance(folder.folder_metadata, dict):
updated = {**folder.folder_metadata, "watched": False}
folder.folder_metadata = updated
await session.commit()
return {"message": "Folder watch status updated"}
@router.put("/folders/{folder_id}", response_model=FolderRead)
async def update_folder(
folder_id: int,
request: FolderUpdate,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Rename a folder. Requires DOCUMENTS_UPDATE permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
auth,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to update folders in this workspace",
)
folder.name = request.name
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
if "uq_folder_space_parent_name" in str(e):
raise HTTPException(
status_code=409,
detail="A folder with this name already exists at this location",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to update folder: {e!s}"
) from e
@router.put("/folders/{folder_id}/move", response_model=FolderRead)
async def move_folder(
folder_id: int,
request: FolderMove,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Move a folder to a new parent. Requires DOCUMENTS_UPDATE permission."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
auth,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move folders in this workspace",
)
if request.new_parent_id is not None:
new_parent = await session.get(Folder, request.new_parent_id)
if not new_parent:
raise HTTPException(
status_code=404, detail="Target parent folder not found"
)
if new_parent.workspace_id != folder.workspace_id:
raise HTTPException(
status_code=400,
detail="Cannot move folder to a different workspace",
)
await check_no_circular_reference(session, folder_id, request.new_parent_id)
subtree_depth = await get_subtree_max_depth(session, folder_id)
await validate_folder_depth(session, request.new_parent_id, subtree_depth)
position = await generate_folder_position(
session, folder.workspace_id, request.new_parent_id
)
folder.parent_id = request.new_parent_id
folder.position = position
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
if "uq_folder_space_parent_name" in str(e):
raise HTTPException(
status_code=409,
detail="A folder with this name already exists at the target location",
) from e
raise HTTPException(
status_code=500, detail=f"Failed to move folder: {e!s}"
) from e
@router.put("/folders/{folder_id}/reorder", response_model=FolderRead)
async def reorder_folder(
folder_id: int,
request: FolderReorder,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Reorder a folder among its siblings via fractional indexing. Requires DOCUMENTS_UPDATE."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
auth,
folder.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to reorder folders in this workspace",
)
position = await generate_folder_position(
session,
folder.workspace_id,
folder.parent_id,
before_position=request.before_position,
after_position=request.after_position,
)
folder.position = position
await session.commit()
await session.refresh(folder)
return folder
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to reorder folder: {e!s}"
) from e
@router.delete("/folders/{folder_id}")
async def delete_folder(
folder_id: int,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Mark documents for deletion and dispatch Celery to delete docs first, then folders."""
try:
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
await check_permission(
session,
auth,
folder.workspace_id,
Permission.DOCUMENTS_DELETE.value,
"You don't have permission to delete folders in this workspace",
)
subtree_ids = await get_folder_subtree_ids(session, folder_id)
doc_result = await session.execute(
select(Document.id).where(
Document.folder_id.in_(subtree_ids),
Document.status["state"].as_string() != "deleting",
)
)
document_ids = list(doc_result.scalars().all())
if document_ids:
await session.execute(
Document.__table__.update()
.where(Document.id.in_(document_ids))
.values(status={"state": "deleting"})
)
await session.commit()
try:
from app.tasks.celery_tasks.document_tasks import (
delete_folder_documents_task,
)
delete_folder_documents_task.delay(
document_ids, folder_subtree_ids=list(subtree_ids)
)
except Exception as err:
if document_ids:
await session.execute(
Document.__table__.update()
.where(Document.id.in_(document_ids))
.values(status={"state": "ready"})
)
await session.commit()
raise HTTPException(
status_code=503,
detail="Could not queue folder deletion. Documents have been restored.",
) from err
return {
"message": "Folder deletion started",
"documents_queued_for_deletion": len(document_ids),
}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to delete folder: {e!s}"
) from e
@router.put("/documents/{document_id}/move")
async def move_document(
document_id: int,
request: DocumentMove,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Move a document to a folder (or root). Requires DOCUMENTS_UPDATE permission."""
try:
result = await session.execute(
select(Document).filter(Document.id == document_id)
)
document = result.scalars().first()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
session,
auth,
document.workspace_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this workspace",
)
if request.folder_id is not None:
target = await session.get(Folder, request.folder_id)
if not target:
raise HTTPException(status_code=404, detail="Target folder not found")
if target.workspace_id != document.workspace_id:
raise HTTPException(
status_code=400,
detail="Cannot move document to a folder in a different workspace",
)
document.folder_id = request.folder_id
await session.commit()
return {"message": "Document moved successfully"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to move document: {e!s}"
) from e
@router.put("/documents/bulk-move")
async def bulk_move_documents(
request: BulkDocumentMove,
session: AsyncSession = Depends(get_async_session),
auth: AuthContext = Depends(get_auth_context),
):
"""Move multiple documents to a folder (or root). Requires DOCUMENTS_UPDATE permission."""
try:
if not request.document_ids:
raise HTTPException(status_code=400, detail="No document IDs provided")
result = await session.execute(
select(Document).filter(Document.id.in_(request.document_ids))
)
documents = result.scalars().all()
if not documents:
raise HTTPException(status_code=404, detail="No documents found")
workspace_ids = {doc.workspace_id for doc in documents}
for ss_id in workspace_ids:
await check_permission(
session,
auth,
ss_id,
Permission.DOCUMENTS_UPDATE.value,
"You don't have permission to move documents in this workspace",
)
if request.folder_id is not None:
target = await session.get(Folder, request.folder_id)
if not target:
raise HTTPException(status_code=404, detail="Target folder not found")
mismatched = [
doc.id
for doc in documents
if doc.workspace_id != target.workspace_id
]
if mismatched:
raise HTTPException(
status_code=400,
detail="Cannot move documents to a folder in a different workspace",
)
for doc in documents:
doc.folder_id = request.folder_id
await session.commit()
return {"message": f"{len(request.document_ids)} documents moved successfully"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to move documents: {e!s}"
) from e