# backend/app/routers/tasks.py import uuid from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ..db import get_session from ..models import Project, Task, TaskComment, TaskStatus from ..schemas import ( CommentCreate, CommentOut, RiskOut, ScaffoldOut, ScaffoldRequest, TaskCreate, TaskNode, TaskPatch, ) from ..services.risk import compute_risks from ..services.scaffold import pick_scaffold, scaffold_create router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록. def _tid() -> str: return "t-" + uuid.uuid4().hex[:8] def folder_of_project(s: Session, project_id: str) -> str | None: p = s.get(Project, project_id) return p.folder_id if p else None def project_subtree_ids(s: Session, root_id: str) -> set[str]: """root_id 와 그 모든 하위 프로젝트 id 집합.""" all_projects = s.exec(select(Project)).all() by_parent: dict[str | None, list[Project]] = {} for p in all_projects: by_parent.setdefault(p.parent_id, []).append(p) ids: set[str] = set() def walk(pid: str): ids.add(pid) for ch in by_parent.get(pid, []): walk(ch.id) walk(root_id) return ids def to_node(s: Session, t: Task, all_tasks: list[Task]) -> TaskNode: kids = sorted([x for x in all_tasks if x.parent_id == t.id], key=lambda x: x.sort_order) comments = s.exec( select(TaskComment) .where(TaskComment.task_id == t.id) .order_by(TaskComment.created_at, TaskComment.id) ).all() return TaskNode( id=t.id, project_id=t.project_id, parent_id=t.parent_id, title=t.title, status=t.status, assignee_id=t.assignee_id, due=t.due, prio=t.prio, notes=t.notes, est=t.est, delegated=t.delegated, sort_order=t.sort_order, created_at=t.created_at, updated_at=t.updated_at, comments=[CommentOut.model_validate(c, from_attributes=True) for c in comments], children=[to_node(s, k, all_tasks) for k in kids], ) @router.get("/tasks", response_model=list[TaskNode]) def list_tasks( area: str | None = None, project_id: str | None = None, status: TaskStatus | None = None, assignee: str | None = None, s: Session = Depends(get_session), ): all_tasks = s.exec(select(Task)).all() roots = [t for t in all_tasks if t.parent_id is None] # 필터 우선순위: project_id(+하위 프로젝트) > area > 전체. status/assignee 는 추가 필터. if project_id: scope = project_subtree_ids(s, project_id) roots = [t for t in roots if t.project_id in scope] elif area: roots = [t for t in roots if folder_of_project(s, t.project_id) == area] if status: roots = [t for t in roots if t.status == status] if assignee: roots = [t for t in roots if t.assignee_id == assignee] roots.sort(key=lambda t: t.sort_order) return [to_node(s, t, all_tasks) for t in roots] @router.get("/tasks/{tid}", response_model=TaskNode) def get_task(tid: str, s: Session = Depends(get_session)): t = s.get(Task, tid) if not t: raise HTTPException(404, "task not found") return to_node(s, t, s.exec(select(Task)).all()) @router.post("/tasks", response_model=TaskNode) def create_task(body: TaskCreate, s: Session = Depends(get_session)): if not s.get(Project, body.project_id): raise HTTPException(404, "project not found") if body.parent_id and not s.get(Task, body.parent_id): raise HTTPException(404, "parent task not found") siblings = s.exec( select(Task).where(Task.parent_id == body.parent_id, Task.project_id == body.project_id) ).all() order = max([x.sort_order for x in siblings] + [-1]) + 1 t = Task( id=_tid(), title=body.title, project_id=body.project_id, parent_id=body.parent_id, status=body.status or TaskStatus.todo, assignee_id=body.assignee_id, due=body.due, prio=body.prio, notes=body.notes or "", est=body.est or "", sort_order=order, ) s.add(t) s.commit() return to_node(s, t, s.exec(select(Task)).all()) @router.patch("/tasks/{tid}", response_model=TaskNode) def patch_task(tid: str, body: TaskPatch, s: Session = Depends(get_session)): t = s.get(Task, tid) if not t: raise HTTPException(404, "task not found") data = body.model_dump(exclude_none=True) # parent_id 이동 시 순환(자기 자신/후손으로 이동) 금지 — to_node 무한재귀 방지 if "parent_id" in data and data["parent_id"]: if data["parent_id"] == tid: raise HTTPException(400, "cannot parent task to itself") cur = s.get(Task, data["parent_id"]) while cur: if cur.id == tid: raise HTTPException(400, "cannot move into own descendant") cur = s.get(Task, cur.parent_id) if cur.parent_id else None for k, v in data.items(): setattr(t, k, v) t.updated_at = datetime.now(UTC) s.add(t) s.commit() return to_node(s, t, s.exec(select(Task)).all()) @router.delete("/tasks/{tid}") def delete_task(tid: str, s: Session = Depends(get_session)): t = s.get(Task, tid) if not t: raise HTTPException(404, "task not found") # 하위작업 재귀 삭제 all_tasks = s.exec(select(Task)).all() def collect(pid): ids = [pid] for c in [x for x in all_tasks if x.parent_id == pid]: ids += collect(c.id) return ids for did in collect(tid): for c in s.exec(select(TaskComment).where(TaskComment.task_id == did)).all(): s.delete(c) d = s.get(Task, did) s.delete(d) s.commit() return {"deleted": tid} @router.post("/tasks/{tid}/comments", response_model=CommentOut) def add_comment(tid: str, body: CommentCreate, s: Session = Depends(get_session)): if not s.get(Task, tid): raise HTTPException(404, "task not found") c = TaskComment( id="c-" + uuid.uuid4().hex[:8], task_id=tid, person_id=body.person_id, text=body.text ) s.add(c) s.commit() s.refresh(c) return CommentOut.model_validate(c, from_attributes=True) @router.post("/tasks/{tid}/scaffold", response_model=ScaffoldOut) def scaffold(tid: str, body: ScaffoldRequest, s: Session = Depends(get_session)): t = s.get(Task, tid) if not t: raise HTTPException(404, "task not found") tpl = pick_scaffold(t.title, use_llm=body.use_llm) if body.create: ids = scaffold_create(s, t, tpl) return ScaffoldOut( kind=tpl["kind"], icon=tpl["icon"], items=tpl["items"], created=True, created_task_ids=ids, ) return ScaffoldOut(kind=tpl["kind"], icon=tpl["icon"], items=tpl["items"], created=False) @router.get("/risks", response_model=list[RiskOut]) def risks(area: str = "work", s: Session = Depends(get_session)): # area 기본값 work. 주어진 area 로 리스크 계산. return compute_risks(s, area=area)