diff --git a/app/modules/project/router.py b/app/modules/project/router.py index 7440d18..8a1b589 100644 --- a/app/modules/project/router.py +++ b/app/modules/project/router.py @@ -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}) diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html index 46e4944..ad5961d 100644 --- a/app/modules/project/templates/project/index.html +++ b/app/modules/project/templates/project/index.html @@ -1,12 +1,14 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} -
+
@@ -50,7 +52,8 @@ data-start="{{ p.start_date or '' }}" data-due="{{ p.due_date or '' }}" data-color="{{ p.color }}" - data-status="{{ p.status or 'active' }}"> + data-status="{{ p.status or 'active' }}" + data-creator="{{ p.created_by or '' }}">
@@ -164,5 +167,5 @@ - + {% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html index a84db7c..5377190 100644 --- a/app/modules/project/templates/project/project.html +++ b/app/modules/project/templates/project/project.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + @@ -23,6 +23,7 @@ data-project-id="{{ project.id }}" data-can-manage="{{ 'true' if can_manage else 'false' }}" data-is-admin="{{ 'true' if is_admin else 'false' }}" + data-is-super="{{ 'true' if user.is_super_admin else 'false' }}" data-me="{{ user.email }}">
@@ -53,7 +54,7 @@ {% if is_admin %} - + {% endif %}
@@ -289,5 +290,5 @@ - + {% endblock %} diff --git a/app/static/project.js b/app/static/project.js index df03ac7..ff1f9bb 100644 --- a/app/static/project.js +++ b/app/static/project.js @@ -62,6 +62,14 @@ function initHome() { const grid = el("pj-project-grid"); if (!grid && !el("pj-toggle-done")) return; // 홈 아님 + const wrap = document.querySelector(".pj-wrap"); + const me = ((wrap && wrap.dataset.me) || "").toLowerCase(); + const isSuper = !!(wrap && wrap.dataset.isSuper === "true"); + function canDelete(createdBy) { + if (isSuper) return true; + const cb = (createdBy || "").toLowerCase(); + return !!cb && cb === me; + } // ── 완료 프로젝트 숨기기 토글 (localStorage 기억) ── const toggleBtn = el("pj-toggle-done"); @@ -140,6 +148,8 @@ }); el("pj-delete-project").addEventListener("click", async function () { const id = el("pj-e-id").value; + const card = grid.querySelector('.pj-project-card[data-id="' + id + '"]'); + if (card && !canDelete(card.dataset.creator)) { alert("삭제는 만든 사람만 할 수 있습니다."); return; } if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return; try { await api("DELETE", "/project/api/projects/" + id); location.reload(); } catch (e) { alert("삭제 실패: " + e.message); } @@ -156,6 +166,15 @@ const projectId = parseInt(root.dataset.projectId, 10); const canManage = root.dataset.canManage === "true"; const isAdmin = root.dataset.isAdmin === "true"; + const me = (root.dataset.me || "").toLowerCase(); + const isSuper = root.dataset.isSuper === "true"; + // 삭제는 만든 사람만(슈퍼관리자 예외). created_by 없으면 서버가 최종 판정. + function canDelete(createdBy) { + if (isSuper) return true; + const cb = (createdBy || "").toLowerCase(); + return !!cb && cb === me; + } + const DELETE_DENIED = "삭제는 만든 사람만 할 수 있습니다."; const dataEl = el("pj-data"); const data = JSON.parse(dataEl.textContent); @@ -918,7 +937,10 @@ el("pj-delete-task").addEventListener("click", async function () { const id = el("pj-t-id").value; - if (!id || !confirm("이 업무를 삭제할까요?")) return; + if (!id) return; + const t = tasks.find(function (x) { return String(x.id) === String(id); }); + if (t && !canDelete(t.created_by)) { alert(DELETE_DENIED); return; } + if (!confirm("이 업무를 삭제할까요?")) return; try { await api("DELETE", "/project/api/tasks/" + id); tasks = tasks.filter(function (x) { return String(x.id) !== String(id); }); @@ -976,6 +998,7 @@ document.querySelectorAll(".pj-tree-del").forEach(function (b) { b.addEventListener("click", async function (e) { e.preventDefault(); e.stopPropagation(); + if (!canDelete(b.dataset.creator)) { alert(DELETE_DENIED); return; } if (!confirm("'" + (b.dataset.name || "") + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return; try { await api("DELETE", "/project/api/projects/" + b.dataset.id);