from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload from typing import List, Optional from datetime import date, timedelta 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, TrackedTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse from app.routers.auth import get_current_user from app.models.user import User 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( tracked: Optional[bool] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get all projects with their tasks. Optionally filter by tracked status.""" query = ( select(Project) .options(*_project_load_options()) .where(Project.user_id == current_user.id) .order_by(Project.created_at.desc()) ) if tracked is not None: query = query.where(Project.is_tracked == tracked) result = await db.execute(query) projects = result.scalars().unique().all() return projects # This route MUST be defined before /{project_id} to avoid path parameter shadowing @router.get("/tracked-tasks", response_model=List[TrackedTaskResponse]) async def get_tracked_tasks( days: int = Query(7, ge=1, le=90), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get tasks and subtasks from tracked projects with due dates within the next N days.""" today = date.today() cutoff = today + timedelta(days=days) query = ( select(ProjectTask) .join(Project, ProjectTask.project_id == Project.id) .options( selectinload(ProjectTask.project), selectinload(ProjectTask.parent_task), ) .where( Project.user_id == current_user.id, Project.is_tracked == True, ProjectTask.due_date.isnot(None), ProjectTask.due_date >= today, ProjectTask.due_date <= cutoff, ProjectTask.status != "completed", ) .order_by(ProjectTask.due_date.asc()) ) result = await db.execute(query) tasks = result.scalars().unique().all() return [ TrackedTaskResponse( id=t.id, title=t.title, status=t.status, priority=t.priority, due_date=t.due_date, project_name=t.project.name, project_id=t.project_id, parent_task_title=t.parent_task.title if t.parent_task else None, ) for t in tasks ] @router.post("/", response_model=ProjectResponse, status_code=201) async def create_project( project: ProjectCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create a new project.""" new_project = Project(**project.model_dump(), user_id=current_user.id) 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 = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get a specific project by ID with its tasks.""" query = ( select(Project) .options(*_project_load_options()) .where(Project.id == project_id, Project.user_id == current_user.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 = Path(ge=1, le=2147483647), project_update: ProjectUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a project.""" result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.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 = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a project and all its tasks.""" result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.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 = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get top-level tasks for a specific project (subtasks are nested).""" # Verify project ownership first result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.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 = Path(ge=1, le=2147483647), task: ProjectTaskCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create a new task or subtask for a project.""" # Verify project ownership first result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.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 = Path(ge=1, le=2147483647), items: List[ReorderItem] = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Bulk update sort_order for tasks.""" # Verify project ownership first result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.id) ) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") # AC-4: Batch-fetch all tasks in one query instead of N sequential queries task_ids = [item.id for item in items] task_result = await db.execute( select(ProjectTask).where( ProjectTask.id.in_(task_ids), ProjectTask.project_id == project_id, ) ) tasks_by_id = {t.id: t for t in task_result.scalars().all()} order_map = {item.id: item.sort_order for item in items} for task_id, task in tasks_by_id.items(): if task_id in order_map: task.sort_order = order_map[task_id] await db.commit() return {"status": "ok"} @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) async def update_project_task( project_id: int = Path(ge=1, le=2147483647), task_id: int = Path(ge=1, le=2147483647), task_update: ProjectTaskUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a project task.""" # Verify project ownership first, then fetch task scoped to that project project_result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.id) ) if not project_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Project not found") 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 = Path(ge=1, le=2147483647), task_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a project task (cascades to subtasks).""" # Verify project ownership first, then fetch task scoped to that project project_result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.id) ) if not project_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Project not found") 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 = Path(ge=1, le=2147483647), task_id: int = Path(ge=1, le=2147483647), comment: TaskCommentCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Add a comment to a task.""" # Verify project ownership first, then fetch task scoped to that project project_result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.id) ) if not project_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Project not found") 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 = Path(ge=1, le=2147483647), task_id: int = Path(ge=1, le=2147483647), comment_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a task comment.""" # Verify project ownership first, then fetch comment scoped through task project_result = await db.execute( select(Project).where(Project.id == project_id, Project.user_id == current_user.id) ) if not project_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="Project not found") 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