Files
claudia-docs-api/app/routers/folders.py
Motoko 7f3e8a8f53 Phase 1 MVP - Complete implementation
- Auth: register, login, JWT with refresh tokens, blocklist
- Projects/Folders/Documents CRUD with soft deletes
- Tags CRUD and assignment
- FTS5 search with highlights and tag filtering
- ADR-001, ADR-002, ADR-003 compliant
- Security fixes applied (JWT_SECRET_KEY, exception handler, cookie secure)
- 25 tests passing
2026-03-30 15:17:27 +00:00

252 lines
7.6 KiB
Python

import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.folder import Folder
from app.models.project import Project
from app.schemas.folder import FolderCreate, FolderListResponse, FolderResponse, FolderUpdate
from app.routers.auth import get_current_agent
router = APIRouter(tags=["folders"])
def build_folder_path(project_id: str, folder_id: str, parent_id: str | None, parent_path: str | None) -> str:
if parent_id and parent_path:
return f"{parent_path}/{folder_id}"
return f"/{project_id}/{folder_id}"
@router.get("/api/v1/projects/{project_id}/folders", response_model=FolderListResponse)
async def list_folders(
request: Request,
project_id: str,
parent_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
query = select(Folder).where(
Folder.project_id == project_id,
Folder.parent_id == parent_id,
Folder.is_deleted == False,
).order_by(Folder.name)
result = await db.execute(query)
folders = result.scalars().all()
return FolderListResponse(folders=[FolderResponse.model_validate(f) for f in folders])
@router.post("/api/v1/projects/{project_id}/folders", response_model=FolderResponse, status_code=201)
async def create_folder(
request: Request,
project_id: str,
payload: FolderCreate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
proj_result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
parent_path = None
if payload.parent_id:
parent_result = await db.execute(
select(Folder).where(
Folder.id == payload.parent_id,
Folder.project_id == project_id,
Folder.is_deleted == False,
)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=400, detail="Parent folder not found")
parent_path = parent.path
folder_id = str(uuid.uuid4())
path = build_folder_path(project_id, folder_id, payload.parent_id, parent_path)
folder = Folder(
id=folder_id,
name=payload.name,
project_id=project_id,
parent_id=payload.parent_id,
path=path,
)
db.add(folder)
await db.flush()
return FolderResponse.model_validate(folder)
@router.get("/api/v1/folders/{folder_id}", response_model=FolderResponse)
async def get_folder(
request: Request,
folder_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == False,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Folder not found")
return FolderResponse.model_validate(folder)
@router.put("/api/v1/folders/{folder_id}", response_model=FolderResponse)
async def update_folder(
request: Request,
folder_id: str,
payload: FolderUpdate,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == False,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
if payload.name is not None:
folder.name = payload.name
if payload.parent_id is not None:
if payload.parent_id == folder_id:
raise HTTPException(status_code=400, detail="Cannot set folder as its own parent")
parent_result = await db.execute(
select(Folder).where(
Folder.id == payload.parent_id,
Folder.project_id == folder.project_id,
Folder.is_deleted == False,
)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=400, detail="Parent folder not found")
folder.parent_id = payload.parent_id
folder.path = f"{parent.path}/{folder.id}"
folder.updated_at = datetime.utcnow()
await db.flush()
return FolderResponse.model_validate(folder)
@router.delete("/api/v1/folders/{folder_id}", status_code=204)
async def delete_folder(
request: Request,
folder_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == False,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
folder.is_deleted = True
folder.deleted_at = datetime.utcnow()
folder.deleted_by = agent.id
await db.flush()
return None
@router.post("/api/v1/folders/{folder_id}/restore", response_model=FolderResponse)
async def restore_folder(
request: Request,
folder_id: str,
db: AsyncSession = Depends(get_db),
):
agent = await get_current_agent(request, db)
result = await db.execute(
select(Folder).where(
Folder.id == folder_id,
Folder.is_deleted == True,
)
)
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
proj_result = await db.execute(
select(Project).where(
Project.id == folder.project_id,
Project.agent_id == agent.id,
Project.is_deleted == False,
)
)
if not proj_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Forbidden")
folder.is_deleted = False
folder.deleted_at = None
folder.deleted_by = None
await db.flush()
return FolderResponse.model_validate(folder)