Chain second-level selectinload(ProjectTask.subtasks) on task create, update, and list endpoints. Pydantic's recursive ProjectTaskResponse schema accesses .subtasks on each subtask, which triggers lazy loading without eager load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
250 lines
7.8 KiB
Python
250 lines
7.8 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).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).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).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
|