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