mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 18:36:23 +02:00
542 lines
18 KiB
Python
542 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.db import Document, Folder, Permission, User, 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 current_active_user
|
|
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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""Create a new folder. Requires DOCUMENTS_CREATE permission."""
|
|
try:
|
|
await check_permission(
|
|
session,
|
|
user,
|
|
request.search_space_id,
|
|
Permission.DOCUMENTS_CREATE.value,
|
|
"You don't have permission to create folders in this search space",
|
|
)
|
|
|
|
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.search_space_id != request.search_space_id:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Parent folder belongs to a different search space",
|
|
)
|
|
|
|
await validate_folder_depth(session, request.parent_id)
|
|
|
|
position = await generate_folder_position(
|
|
session, request.search_space_id, request.parent_id
|
|
)
|
|
|
|
folder = Folder(
|
|
name=request.name,
|
|
position=position,
|
|
parent_id=request.parent_id,
|
|
search_space_id=request.search_space_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(
|
|
search_space_id: int,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""List all folders in a search space (flat). Requires DOCUMENTS_READ permission."""
|
|
try:
|
|
await check_permission(
|
|
session,
|
|
user,
|
|
search_space_id,
|
|
Permission.DOCUMENTS_READ.value,
|
|
"You don't have permission to read folders in this search space",
|
|
)
|
|
|
|
result = await session.execute(
|
|
select(Folder)
|
|
.where(Folder.search_space_id == search_space_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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
folder.search_space_id,
|
|
Permission.DOCUMENTS_READ.value,
|
|
"You don't have permission to read folders in this search space",
|
|
)
|
|
|
|
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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
folder.search_space_id,
|
|
Permission.DOCUMENTS_READ.value,
|
|
"You don't have permission to read folders in this search space",
|
|
)
|
|
|
|
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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
folder.search_space_id,
|
|
Permission.DOCUMENTS_UPDATE.value,
|
|
"You don't have permission to update folders in this search space",
|
|
)
|
|
|
|
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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
folder.search_space_id,
|
|
Permission.DOCUMENTS_UPDATE.value,
|
|
"You don't have permission to update folders in this search space",
|
|
)
|
|
|
|
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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
folder.search_space_id,
|
|
Permission.DOCUMENTS_UPDATE.value,
|
|
"You don't have permission to move folders in this search space",
|
|
)
|
|
|
|
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.search_space_id != folder.search_space_id:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot move folder to a different search space",
|
|
)
|
|
|
|
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.search_space_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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
folder.search_space_id,
|
|
Permission.DOCUMENTS_UPDATE.value,
|
|
"You don't have permission to reorder folders in this search space",
|
|
)
|
|
|
|
position = await generate_folder_position(
|
|
session,
|
|
folder.search_space_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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
folder.search_space_id,
|
|
Permission.DOCUMENTS_DELETE.value,
|
|
"You don't have permission to delete folders in this search space",
|
|
)
|
|
|
|
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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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,
|
|
user,
|
|
document.search_space_id,
|
|
Permission.DOCUMENTS_UPDATE.value,
|
|
"You don't have permission to move documents in this search space",
|
|
)
|
|
|
|
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.search_space_id != document.search_space_id:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot move document to a folder in a different search space",
|
|
)
|
|
|
|
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),
|
|
user: User = Depends(current_active_user),
|
|
):
|
|
"""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")
|
|
|
|
search_space_ids = {doc.search_space_id for doc in documents}
|
|
for ss_id in search_space_ids:
|
|
await check_permission(
|
|
session,
|
|
user,
|
|
ss_id,
|
|
Permission.DOCUMENTS_UPDATE.value,
|
|
"You don't have permission to move documents in this search space",
|
|
)
|
|
|
|
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.search_space_id != target.search_space_id
|
|
]
|
|
if mismatched:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot move documents to a folder in a different search space",
|
|
)
|
|
|
|
await session.execute(
|
|
Document.__table__.update()
|
|
.where(Document.id.in_(request.document_ids))
|
|
.values(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
|