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 pydantic import BaseModel from app.database import get_db from app.models.project import Project from app.models.project_task import ProjectTask from app.models.task_comment import TaskComment from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse from app.routers.auth import get_current_session from app.models.settings import Settings router = APIRouter() class ReorderItem(BaseModel): id: int sort_order: int def _project_load_options(): """All load options needed for project responses (tasks + subtasks + comments at each level).""" return [ selectinload(Project.tasks).selectinload(ProjectTask.comments), selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), ] def _task_load_options(): """All load options needed for task responses.""" return [ selectinload(ProjectTask.comments), selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), selectinload(ProjectTask.subtasks).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_load_options()).order_by(Project.created_at.desc()) result = await db.execute(query) projects = result.scalars().unique().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_load_options()).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_load_options()).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_load_options()).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(*_task_load_options()) .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().unique().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(*_task_load_options()) .where(ProjectTask.id == new_task.id) ) result = await db.execute(query) return result.scalar_one() @router.put("/{project_id}/tasks/reorder", status_code=200) async def reorder_tasks( project_id: int, items: List[ReorderItem], db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): """Bulk update sort_order for 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") for item in items: task_result = await db.execute( select(ProjectTask).where( ProjectTask.id == item.id, ProjectTask.project_id == project_id ) ) task = task_result.scalar_one_or_none() if task: task.sort_order = item.sort_order await db.commit() return {"status": "ok"} @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(*_task_load_options()) .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 @router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) async def create_task_comment( project_id: int, task_id: int, comment: TaskCommentCreate, db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): """Add a comment to a 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") new_comment = TaskComment(task_id=task_id, content=comment.content) db.add(new_comment) await db.commit() await db.refresh(new_comment) return new_comment @router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204) async def delete_task_comment( project_id: int, task_id: int, comment_id: int, db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): """Delete a task comment.""" result = await db.execute( select(TaskComment).where( TaskComment.id == comment_id, TaskComment.task_id == task_id ) ) comment = result.scalar_one_or_none() if not comment: raise HTTPException(status_code=404, detail="Comment not found") await db.delete(comment) await db.commit() return None