You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

200 lines
6.9 KiB
Python

# 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)