# backend/app/routers/tree.py import uuid from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ..db import get_session from ..models import Folder, Project, Task from ..schemas import ( FolderCreate, FolderOut, FolderPatch, ProjectCreate, ProjectNode, ProjectPatch, ) router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록. def _slug() -> str: return "p-" + uuid.uuid4().hex[:8] def build_tree(s: Session) -> list[FolderOut]: folders = s.exec(select(Folder).order_by(Folder.sort_order)).all() projects = s.exec(select(Project).order_by(Project.sort_order)).all() # 작업 수: project_id 별 직접 작업 수 direct: dict[str, int] = {} for t in s.exec(select(Task)).all(): direct[t.project_id] = direct.get(t.project_id, 0) + 1 by_parent: dict[str | None, list[Project]] = {} for p in projects: by_parent.setdefault(p.parent_id, []).append(p) def count_recursive(pid: str) -> int: total = direct.get(pid, 0) for ch in by_parent.get(pid, []): total += count_recursive(ch.id) return total def node(p: Project) -> ProjectNode: kids = [node(c) for c in by_parent.get(p.id, [])] return ProjectNode( id=p.id, folder_id=p.folder_id, parent_id=p.parent_id, name=p.name, tone=p.tone, sort_order=p.sort_order, pinned=p.pinned, task_count=count_recursive(p.id), children=kids, ) out = [] for f in folders: roots = [node(p) for p in by_parent.get(None, []) if p.folder_id == f.id] out.append( FolderOut( id=f.id, name=f.name, tone=f.tone, icon=f.icon, sort_order=f.sort_order, is_system=f.is_system, projects=roots, ) ) return out @router.get("/tree", response_model=list[FolderOut]) def get_tree(s: Session = Depends(get_session)): return build_tree(s) @router.post("/folders", response_model=FolderOut) def create_folder(body: FolderCreate, s: Session = Depends(get_session)): fid = _slug() mx = max([f.sort_order for f in s.exec(select(Folder)).all()] + [-1]) + 1 f = Folder( id=fid, name=body.name, tone=body.tone or "ink", icon=body.icon or "folder", sort_order=mx, is_system=False, ) s.add(f) s.commit() return [x for x in build_tree(s) if x.id == fid][0] @router.patch("/folders/{fid}", response_model=FolderOut) def patch_folder(fid: str, body: FolderPatch, s: Session = Depends(get_session)): f = s.get(Folder, fid) if not f: raise HTTPException(404, "folder not found") for k, v in body.model_dump(exclude_none=True).items(): setattr(f, k, v) s.add(f) s.commit() return [x for x in build_tree(s) if x.id == fid][0] @router.delete("/folders/{fid}") def delete_folder(fid: str, s: Session = Depends(get_session)): f = s.get(Folder, fid) if not f: raise HTTPException(404, "folder not found") if f.is_system: raise HTTPException(400, "system folder cannot be deleted") if s.exec(select(Project).where(Project.folder_id == fid)).first(): raise HTTPException(400, "folder has projects") s.delete(f) s.commit() return {"deleted": fid} @router.post("/projects", response_model=ProjectNode) def create_project(body: ProjectCreate, s: Session = Depends(get_session)): if not s.get(Folder, body.folder_id): raise HTTPException(404, "folder not found") if body.parent_id and not s.get(Project, body.parent_id): raise HTTPException(404, "parent project not found") pid = _slug() siblings = s.exec( select(Project).where( Project.parent_id == body.parent_id, Project.folder_id == body.folder_id ) ).all() order = max([p.sort_order for p in siblings] + [-1]) + 1 p = Project( id=pid, folder_id=body.folder_id, parent_id=body.parent_id, name=body.name, tone=body.tone or "ink", sort_order=order, ) s.add(p) s.commit() s.refresh(p) return ProjectNode( id=p.id, folder_id=p.folder_id, parent_id=p.parent_id, name=p.name, tone=p.tone, sort_order=p.sort_order, pinned=p.pinned, task_count=0, children=[], ) @router.patch("/projects/{pid}", response_model=ProjectNode) def patch_project(pid: str, body: ProjectPatch, s: Session = Depends(get_session)): p = s.get(Project, pid) if not p: raise HTTPException(404, "project not found") data = body.model_dump(exclude_none=True) # parent_id 이동 시 순환(자기 자신/후손으로 이동) 금지 if "parent_id" in data and data["parent_id"]: cur = s.get(Project, data["parent_id"]) while cur: if cur.id == pid: raise HTTPException(400, "cannot move into own descendant") cur = s.get(Project, cur.parent_id) if cur.parent_id else None for k, v in data.items(): setattr(p, k, v) s.add(p) s.commit() s.refresh(p) def find(nodes): for n in nodes: if n.id == pid: return n r = find(n.children) if r: return r for f in build_tree(s): r = find(f.projects) if r: return r raise HTTPException(500, "rebuild failed") @router.delete("/projects/{pid}") def delete_project(pid: str, s: Session = Depends(get_session)): p = s.get(Project, pid) if not p: raise HTTPException(404, "project not found") if s.exec(select(Project).where(Project.parent_id == pid)).first(): raise HTTPException(400, "project has sub-projects") if s.exec(select(Task).where(Task.project_id == pid)).first(): raise HTTPException(400, "project has tasks") s.delete(p) s.commit() return {"deleted": pid} @router.post("/projects/{pid}/pin", response_model=ProjectNode) def pin_project(pid: str, s: Session = Depends(get_session)): p = s.get(Project, pid) if not p: raise HTTPException(404, "project not found") p.pinned = not p.pinned s.add(p) s.commit() s.refresh(p) return ProjectNode( id=p.id, folder_id=p.folder_id, parent_id=p.parent_id, name=p.name, tone=p.tone, sort_order=p.sort_order, pinned=p.pinned, task_count=0, children=[], )