feat(project): 3단계 구조 백엔드 기반 — 세션 삭제자·하위업무·멀티호밍
프로젝트→세션→업무 3단계(아사나식) 전환의 1단계(백엔드). "세션"은 새 개념이
아니라 기존 "단계"(project_stages, 칸반 컬럼)를 그대로 재사용한다.
- project_stages 에 created_by 추가 — 세션 삭제도 "만든 사람만"(_can_delete
재사용) 규칙 적용. api_delete_stage 를 _require_manage 단독 체크에서
생성자 체크로 변경.
- 프로젝트 생성을 관리자 전용 → 로그인 사용자 전체로 개방
(api_create_project: _require_admin → _require_user). 홈 화면의 "새
프로젝트"/수정 모달·버튼도 is_admin 게이트를 걷어냄(생성자가 자동으로
owner 가 되어 _can_manage 통과 — 기존 로직 재사용). 멤버 배정 API는 계속
관리자 전용이라, 비관리자가 새 프로젝트 모달을 열면 멤버 선택란은 안내
문구만 보여주고 시도하지 않는다(불필요한 403 방지).
- 하위 업무 — 새 테이블 없이 tasks.parent_task_id 재사용(부모 삭제 시
CASCADE). list_tasks 는 기본적으로 최상위만 반환(보드/캘린더/리스트에
하위업무가 카드로 안 뜨게) + comment_count 와 같은 방식으로 subtask_total/
subtask_done 집계 추가. create_task 에 parent_task_id 지원(하위업무는
부모와 같은 프로젝트, 세션 없음).
- 멀티호밍(task_project_links, 아사나 기능) — 업무 하나가 여러 프로젝트/세션에
동시에 속할 수 있다. list_tasks(project_id=X, 비재귀)가 자동으로 그
프로젝트에 연결된 업무까지 포함하도록 확장(다른 호출 경로는 안 건드림 —
홈/내업무 전체보기에서 중복 노출 방지). 신규 API: GET /api/tasks/{id}(하위
업무·연결 포함 상세), POST/DELETE /api/tasks/{id}/links.
- 설명란 이미지 삽입 준비: store.sanitize_description_html — 허용 태그만
남기고 스크립트/이벤트속성/위험 URL 스킴 제거(표준 html.parser, 외부
라이브러리 없음). api_create_task/api_update_task 저장 직전 통과.
⚠️ 배포 전 필수: scripts/sql/project_db_003_sections_subtasks_links.sql 를
project_db 에 적용해야 한다(project_stages.created_by, tasks.parent_task_id,
task_project_links 없이는 이 커밋의 쿼리가 전부 실패한다). 그 안의 기존
설명 텍스트→HTML 이스케이프 UPDATE 문은 1회만 실행할 것.
프론트엔드(세션 드래그 재정렬, 사이드바 프로젝트별 보기, 하위업무/멀티호밍
UI, 설명란 이미지 붙여넣기 편집기)는 다음 커밋에서 이어간다.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -570,6 +570,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
|
||||
"page_title": project["name"],
|
||||
"page_subtitle": project.get("description") or "프로젝트 보드",
|
||||
"project": project,
|
||||
"current_project_id": project_id,
|
||||
"nav_projects": nav_projects,
|
||||
"stages": stages,
|
||||
"tasks": all_tasks,
|
||||
@@ -619,8 +620,8 @@ async def api_list_projects(request: Request) -> JSONResponse:
|
||||
async def api_create_project(
|
||||
request: Request, payload: dict[str, Any] = Body(...)
|
||||
) -> JSONResponse:
|
||||
"""최상위 프로젝트 생성 — 관리자 전용."""
|
||||
user = _require_admin(request)
|
||||
"""최상위 프로젝트 생성 — 로그인해 프로젝트 모듈에 접근할 수 있는 사용자 누구나."""
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
try:
|
||||
project = st.create_project(
|
||||
@@ -778,6 +779,7 @@ async def api_add_stage(
|
||||
project_id=project_id,
|
||||
name=payload.get("name", ""),
|
||||
is_done_stage=bool(payload.get("is_done_stage", False)),
|
||||
created_by=user["email"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
@@ -788,13 +790,20 @@ async def api_add_stage(
|
||||
async def api_delete_stage(
|
||||
request: Request, project_id: int, stage_id: int
|
||||
) -> JSONResponse:
|
||||
"""세션(단계) 삭제 — 만든 사람만(슈퍼관리자 예외). 생성자 정보가 없는 과거
|
||||
데이터는 관리자에게 허용(잠김 방지) — `_can_delete` 규칙 그대로 재사용."""
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
_require_manage(request, st, user, project_id)
|
||||
stage = st.get_stage(stage_id=stage_id)
|
||||
if stage is None:
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.")
|
||||
if not _can_delete(user, stage.get("created_by")):
|
||||
raise HTTPException(status_code=403, detail=_DELETE_DENIED)
|
||||
try:
|
||||
st.delete_stage(stage_id=stage_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="단계를 찾을 수 없습니다.")
|
||||
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다.")
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@@ -834,7 +843,7 @@ async def api_create_task(
|
||||
project_id=project_id,
|
||||
title=payload.get("title", ""),
|
||||
stage_id=payload.get("stage_id"),
|
||||
description=payload.get("description", ""),
|
||||
description=store.sanitize_description_html(payload.get("description", "")),
|
||||
assignee_email=payload.get("assignee_email", ""),
|
||||
assignee_name=payload.get("assignee_name", ""),
|
||||
priority=payload.get("priority", "normal"),
|
||||
@@ -843,9 +852,12 @@ async def api_create_task(
|
||||
start_time=payload.get("start_time"),
|
||||
due_time=payload.get("due_time"),
|
||||
created_by=user["email"],
|
||||
parent_task_id=payload.get("parent_task_id"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
# 생성과 동시에 담당자 지정 시 관리자 메일 + 담당자 인앱 알림
|
||||
if task.get("assignee_email"):
|
||||
_notify(
|
||||
@@ -886,6 +898,8 @@ async def api_update_task(
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||
_require_manage(request, st, user, existing["project_id"])
|
||||
if "description" in payload:
|
||||
payload["description"] = store.sanitize_description_html(payload["description"])
|
||||
try:
|
||||
task = st.update_task(task_id=task_id, fields=payload, actor=user["email"])
|
||||
except ValueError as exc:
|
||||
@@ -952,6 +966,65 @@ async def api_delete_task(request: Request, task_id: int) -> JSONResponse:
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/tasks/{task_id}")
|
||||
async def api_get_task(request: Request, task_id: int) -> JSONResponse:
|
||||
"""업무 상세 — 하위 업무·연결된 프로젝트 포함. 업무 편집 팝업을 열 때 쓴다."""
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
task = st.get_task(task_id=task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||
_require_manage(request, st, user, task["project_id"])
|
||||
task["subtasks"] = st.list_tasks(parent_task_id=task_id)
|
||||
task["links"] = st.list_task_links(task_id=task_id)
|
||||
return JSONResponse({"task": task})
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# JSON API — 업무 멀티호밍 (다른 프로젝트/세션에 연결)
|
||||
# ────────────────────────────────────────────────────────────
|
||||
@router.post("/api/tasks/{task_id}/links")
|
||||
async def api_add_task_link(
|
||||
request: Request, task_id: int, payload: dict[str, Any] = Body(...)
|
||||
) -> JSONResponse:
|
||||
"""업무를 다른 프로젝트(의 한 세션)에도 연결한다(아사나의 멀티호밍).
|
||||
|
||||
원래 소속(프로젝트)과 연결할 대상 프로젝트 양쪽에 대한 관리 권한이 필요하다.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
task = st.get_task(task_id=task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||
_require_manage(request, st, user, task["project_id"])
|
||||
try:
|
||||
target_project_id = int(payload.get("project_id"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="project_id 가 필요합니다.")
|
||||
if target_project_id == task["project_id"]:
|
||||
raise HTTPException(status_code=400, detail="이미 이 업무의 기본 프로젝트입니다.")
|
||||
_require_manage(request, st, user, target_project_id)
|
||||
stage_id = payload.get("stage_id")
|
||||
link = st.add_task_link(
|
||||
task_id=task_id, project_id=target_project_id,
|
||||
stage_id=int(stage_id) if stage_id else None, created_by=user["email"],
|
||||
)
|
||||
return JSONResponse({"link": link}, status_code=201)
|
||||
|
||||
|
||||
@router.delete("/api/tasks/{task_id}/links/{project_id}")
|
||||
async def api_remove_task_link(request: Request, task_id: int, project_id: int) -> JSONResponse:
|
||||
user = _require_user(request)
|
||||
st = _db_or_503(request)
|
||||
task = st.get_task(task_id=task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
|
||||
_require_manage(request, st, user, task["project_id"])
|
||||
_require_manage(request, st, user, project_id)
|
||||
st.remove_task_link(task_id=task_id, project_id=project_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.get("/api/projects/{project_id}/activity")
|
||||
async def api_activity(request: Request, project_id: int) -> JSONResponse:
|
||||
_require_user(request)
|
||||
|
||||
Reference in New Issue
Block a user