feat(project): 업무·프로젝트 삭제를 '만든 사람'만 허용 + 경고

- 서버: api_delete_task/api_delete_project 가 created_by 일치자만 허용
  (슈퍼관리자 예외, created_by 없는 과거 데이터는 관리자 허용). 403 반환.
- 클라이언트: 삭제 버튼 클릭 시 만든 사람이 아니면 경고창 후 중단
  (created_by 를 트리 삭제버튼·홈 카드·업무 객체로 전달)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 17:10:24 +09:00
parent 523b9b3150
commit f8f4cded66
4 changed files with 58 additions and 21 deletions
+23 -13
View File
@@ -93,6 +93,23 @@ def _require_manage(request: Request, st: Any, user: dict[str, Any], project_id:
raise HTTPException(status_code=403, detail="이 프로젝트에 대한 권한이 없습니다.")
def _can_delete(user: dict[str, Any], created_by: str | None) -> bool:
"""삭제는 '만든 사람'만. 슈퍼관리자는 예외 허용.
생성자 정보가 없는 과거 데이터는 관리자에게 허용(잠김 방지)."""
from app.store import SUPER_ADMIN_EMAIL, is_admin # noqa: WPS433
me = store.norm_str(user.get("email", ""), lower=True)
cb = store.norm_str(created_by or "", lower=True)
if me == SUPER_ADMIN_EMAIL:
return True
if not cb:
return is_admin(user)
return me == cb
_DELETE_DENIED = "삭제는 만든 사람만 할 수 있습니다."
def _admin_emails(request: Request) -> list[str]:
"""알림 수신 관리자 이메일 목록.
@@ -406,6 +423,7 @@ async def project_page(request: Request, project_id: int) -> HTMLResponse:
"id": p["id"],
"name": p["name"],
"color": p.get("color") or "#4573d2",
"created_by": p.get("created_by") or "",
"tasks": [
{"id": t["id"], "title": t["title"],
"color": t.get("project_color") or p.get("color") or "#4573d2",
@@ -558,23 +576,14 @@ async def api_update_project(
@router.delete("/api/projects/{project_id}")
async def api_delete_project(request: Request, project_id: int) -> JSONResponse:
"""프로젝트 삭제 — 서브프로젝트는 멤버도, 최상위는 관리자만.
아사나 요구사항: 배정된 사용자는 '서브프로젝트' 추가/삭제 가능.
최상위 프로젝트 삭제는 관리자 전용.
"""
from app.store import is_admin # noqa: WPS433
"""프로젝트 삭제 — '만든 사람'만(슈퍼관리자 예외). 안의 업무도 CASCADE."""
user = _require_user(request)
st = _db_or_503(request)
project = st.get_project(project_id=project_id)
if project is None:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
is_sub = project.get("parent_id") is not None
if is_sub:
_require_manage(request, st, user, project_id)
elif not is_admin(user):
raise HTTPException(status_code=403, detail="최상위 프로젝트 삭제는 관리자만 가능합니다.")
if not _can_delete(user, project.get("created_by")):
raise HTTPException(status_code=403, detail=_DELETE_DENIED)
st.delete_project(project_id=project_id)
return JSONResponse({"ok": True})
@@ -831,7 +840,8 @@ async def api_delete_task(request: Request, task_id: int) -> JSONResponse:
existing = st.get_task(task_id=task_id)
if existing is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, existing["project_id"])
if not _can_delete(user, existing.get("created_by")):
raise HTTPException(status_code=403, detail=_DELETE_DENIED)
st.delete_task(task_id=task_id)
return JSONResponse({"ok": True})