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.

226 lines
6.6 KiB
Python

# 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=[],
)