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