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.

264 lines
8.7 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 ..auth.deps import current_user
from ..auth.scope import owned_or_404, scoped
from ..db import get_session
from ..models import Person, Project, Task, TaskComment, TaskStatus
from ..schemas import (
CommentCreate,
CommentOut,
CommentPatch,
RiskOut,
ScaffoldOut,
ScaffoldRequest,
TaskCreate,
TaskNode,
TaskPatch,
TaskReorderRequest,
)
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),
user: Person = Depends(current_user),
):
all_tasks = s.exec(scoped(select(Task), Task, user.id)).all() # phase-15 소유자 스코프
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), user: Person = Depends(current_user)):
t = owned_or_404(s.get(Task, tid), user.id) # 타인 소유 → 404(IDOR 방어)
return to_node(s, t, s.exec(scoped(select(Task), Task, user.id)).all())
@router.post("/tasks", response_model=TaskNode)
def create_task(
body: TaskCreate, s: Session = Depends(get_session), user: Person = Depends(current_user)
):
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(),
user_id=user.id, # phase-15: 소유자는 항상 current_user(요청 바디 무시)
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/reorder")
def reorder_tasks(body: TaskReorderRequest, s: Session = Depends(get_session)):
"""같은 컬럼 내 드래그 정렬 — 받은 순서대로 sort_order 재배치."""
n = 0
for i, tid in enumerate(body.ids):
t = s.get(Task, tid)
if t:
t.sort_order = i
t.updated_at = datetime.now(UTC)
s.add(t)
n += 1
s.commit()
return {"ok": True, "count": n}
@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.patch("/tasks/{tid}/comments/{cid}", response_model=CommentOut)
def edit_comment(tid: str, cid: str, body: CommentPatch, s: Session = Depends(get_session)):
c = s.get(TaskComment, cid)
if not c or c.task_id != tid:
raise HTTPException(404, "comment not found")
c.text = body.text
c.edited_at = datetime.now(UTC)
s.add(c)
s.commit()
s.refresh(c)
return CommentOut.model_validate(c, from_attributes=True)
@router.delete("/tasks/{tid}/comments/{cid}")
def delete_comment(tid: str, cid: str, s: Session = Depends(get_session)):
c = s.get(TaskComment, cid)
if not c or c.task_id != tid:
raise HTTPException(404, "comment not found")
s.delete(c)
s.commit()
return {"ok": True, "id": cid}
@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)