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