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
This commit is contained in:
251
app/routers/folders.py
Normal file
251
app/routers/folders.py
Normal file
@@ -0,0 +1,251 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user