UMBRA/backend/app/routers/projects.py
Kyle Pope ccfbf6df96 Add subtasks feature to project tasks
Backend:
- Add self-referencing parent_task_id FK on project_tasks with CASCADE delete
- Add Alembic migration 002 for parent_task_id column + index
- Update schemas with parent_task_id in create, nested subtasks in response
- Chain selectinload for subtasks on all project queries
- Validate parent must be top-level task (single nesting level only)

Frontend:
- Add parent_task_id and subtasks[] to ProjectTask type
- ProjectDetail: expand/collapse chevrons, subtask progress bars, inline
  subtask rendering with accent left border, add/edit/delete subtask buttons
- TaskForm: accept parentTaskId prop, include in create payload, context-aware
  dialog title (New Task vs New Subtask)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:31:46 +08:00

250 lines
7.7 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from typing import List
from app.database import get_db
from app.models.project import Project
from app.models.project_task import ProjectTask
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
def _project_with_tasks():
"""Eager load projects with tasks and their subtasks."""
return selectinload(Project.tasks).selectinload(ProjectTask.subtasks)
@router.get("/", response_model=List[ProjectResponse])
async def get_projects(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all projects with their tasks."""
query = select(Project).options(_project_with_tasks()).order_by(Project.created_at.desc())
result = await db.execute(query)
projects = result.scalars().all()
return projects
@router.post("/", response_model=ProjectResponse, status_code=201)
async def create_project(
project: ProjectCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new project."""
new_project = Project(**project.model_dump())
db.add(new_project)
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(_project_with_tasks()).where(Project.id == new_project.id)
result = await db.execute(query)
return result.scalar_one()
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get a specific project by ID with its tasks."""
query = select(Project).options(_project_with_tasks()).where(Project.id == project_id)
result = await db.execute(query)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int,
project_update: ProjectUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
update_data = project_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(project, key, value)
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(_project_with_tasks()).where(Project.id == project_id)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a project and all its tasks."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
await db.commit()
return None
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get top-level tasks for a specific project (subtasks are nested)."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks))
.where(
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None),
)
.order_by(ProjectTask.sort_order.asc())
)
result = await db.execute(query)
tasks = result.scalars().all()
return tasks
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
async def create_project_task(
project_id: int,
task: ProjectTaskCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new task or subtask for a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Validate parent_task_id if creating a subtask
if task.parent_task_id is not None:
parent_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task.parent_task_id,
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None), # Parent must be top-level
)
)
parent_task = parent_result.scalar_one_or_none()
if not parent_task:
raise HTTPException(
status_code=400,
detail="Parent task not found or is itself a subtask",
)
task_data = task.model_dump()
task_data["project_id"] = project_id
new_task = ProjectTask(**task_data)
db.add(new_task)
await db.commit()
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks))
.where(ProjectTask.id == new_task.id)
)
result = await db.execute(query)
return result.scalar_one()
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
async def update_project_task(
project_id: int,
task_id: int,
task_update: ProjectTaskUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Update a project task."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(task, key, value)
await db.commit()
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks))
.where(ProjectTask.id == task_id)
)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
async def delete_project_task(
project_id: int,
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a project task (cascades to subtasks)."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await db.delete(task)
await db.commit()
return None