Files
Aislo/B01_Dashboard/B01_Dashboard_Router.py
T
eomsangdonandClaude Opus 5 fbab749ec4 feat(B01): 프로젝트 삭제에 개발용 하드 삭제 스위치 추가
대시보드 삭제 버튼은 지금까지 projects.deleted_at만 찍는 소프트 삭제였다. 배포에서는
그게 맞다 — 사용자가 올린 라이다 원본은 다른 프로젝트에 재활용할 자산이다. 그러나 개발
중에는 프로젝트를 반복 생성·삭제하는데 정리 잡이 없어 수십 GB 원본이 계속 쌓인다.

config_system.py 맨 위에 PROJECT_DELETE_HARD_ENABLED를 두고 갈랐다. 기본값 False라
환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다. True면 projects 행을 실제로
DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다) storage/{회사}/{사용자}/{프로젝트ID}/
폴더를 통째로 지운다.

자식 테이블 목록은 코드에 나열하지 않았다. projects.id 참조가 전부 ON DELETE CASCADE라
행 하나면 충분하고, 목록을 복사해 두면 스키마가 바뀔 때 조용히 어긋난다.

순서는 DB 먼저 커밋, rmtree 나중이다. 파일을 먼저 지우면 DB 실패 시 실체 없는 프로젝트가
목록에 남아 화면이 깨진다. 반대면 rmtree가 실패해도 고아 폴더만 남고 정합성은 유지된다.

resolve_stored_project_path()는 끝에서 makedirs를 하므로 삭제에 쓸 수 없다 — 지우기 직전에
폴더를 되살린다. 검증만 하는 resolve_project_root_for_delete()를 따로 뒀고, 저장소 루트 안 ·
세그먼트 정확히 4개 · 마지막 세그먼트가 요청 project_id와 일치를 모두 요구한다. DB의
storage_path가 오염돼도 상위 폴더나 남의 폴더를 지우지 못한다.

하드 삭제 모드에서는 확인 모달 문구를 바꿔 원본까지 사라진다고 알린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 21:56:17 +09:00

355 lines
12 KiB
Python

"""B01_Dashboard 역할별 대시보드 API."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from common_util.common_util_auth import require_system_admin, verify_session
from common_util.common_util_project_delete import hard_delete_project
from config.config_system import PROJECT_DELETE_HARD_ENABLED
from .B01_Dashboard_Repository import (
add_company_member,
assign_user_company,
change_user_role,
create_company,
get_dashboard_me,
get_project,
get_system_resources,
get_user_admin_target,
get_user_company,
join_company,
list_all_companies,
list_all_projects,
list_all_users,
list_audit_logs,
list_company_members,
list_company_projects,
list_join_requests,
list_user_projects,
process_join_request,
remove_company_member,
search_companies,
soft_delete_project,
update_admin_user,
update_project,
update_user_profile,
)
from .B01_Dashboard_Schema import (
AddMemberRequest,
AdminUpdateUserRequest,
AssignCompanyRequest,
ChangeUserRoleRequest,
CreateCompanyRequest,
JoinCompanyRequest,
ProcessJoinRequest,
UpdateProjectRequest,
UpdateUserRequest,
)
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
async def require_company_admin(
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]:
raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.")
return session
def _require_company_id(session: dict[str, Any]) -> int:
company_id = session.get("company_id")
if company_id is None:
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
return int(company_id)
def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
if session["role"] == "SYSTEM_ADMIN":
return True
if session["role"] == "ADMIN":
return _same_company(session, project.get("company_id"))
return False
def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool:
if session["role"] == "SYSTEM_ADMIN":
return True
if session["role"] == "ADMIN":
return _same_company(session, target.get("company_id"))
return int(session["user_id"]) == int(target["id"])
@router.get("/me")
async def dashboard_me(session: dict[str, Any] = Depends(verify_session)):
user = await get_dashboard_me(int(session["user_id"]))
if not user:
raise HTTPException(status_code=404, detail="사용자 정보를 찾을 수 없습니다.")
# 삭제 버튼이 원본까지 지우는 모드인지 화면에 알려 경고 문구를 바꾸게 한다.
user["project_delete_hard"] = PROJECT_DELETE_HARD_ENABLED
return {"status": "success", "user": user}
@router.patch("/me")
async def patch_dashboard_me(
payload: UpdateUserRequest,
session: dict[str, Any] = Depends(verify_session),
):
user = await update_user_profile(int(session["user_id"]), payload.model_dump())
return {"status": "success", "user": user}
@router.get("/user/projects")
async def user_projects(session: dict[str, Any] = Depends(verify_session)):
return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))}
@router.get("/user/company")
async def user_company(session: dict[str, Any] = Depends(verify_session)):
return {"status": "success", "company": await get_user_company(session.get("company_id"))}
@router.get("/user/companies")
async def user_company_search(
q: str = Query(min_length=1, max_length=100),
session: dict[str, Any] = Depends(verify_session),
):
_ = session
return {"status": "success", "companies": await search_companies(q.strip())}
@router.post("/user/company/create")
async def user_company_create(
payload: CreateCompanyRequest,
session: dict[str, Any] = Depends(verify_session),
):
result = await create_company(int(session["user_id"]), payload.model_dump())
return {"status": "success", **result}
@router.post("/user/company/join")
async def user_company_join(
payload: JoinCompanyRequest,
session: dict[str, Any] = Depends(verify_session),
):
result = await join_company(int(session["user_id"]), payload.company_id)
return {"status": "success", **result}
@router.get("/admin/members")
async def admin_members(session: dict[str, Any] = Depends(require_company_admin)):
return {
"status": "success",
"members": await list_company_members(_require_company_id(session)),
}
@router.post("/admin/members")
async def admin_add_member(
payload: AddMemberRequest,
session: dict[str, Any] = Depends(require_company_admin),
):
member = await add_company_member(_require_company_id(session), payload.email)
if not member:
raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.")
return {"status": "success", "member": member}
@router.delete("/admin/members/{user_id}")
async def admin_remove_member(
user_id: int, session: dict[str, Any] = Depends(require_company_admin)
):
if not await remove_company_member(_require_company_id(session), user_id):
raise HTTPException(status_code=404, detail="제거할 팀원을 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/admin/join-requests")
async def admin_join_requests(session: dict[str, Any] = Depends(require_company_admin)):
return {"status": "success", "requests": await list_join_requests(_require_company_id(session))}
@router.patch("/admin/join-requests/{request_id}")
async def admin_process_join_request(
request_id: int,
payload: ProcessJoinRequest,
session: dict[str, Any] = Depends(require_company_admin),
):
company_id = None if session["role"] == "SYSTEM_ADMIN" else _require_company_id(session)
changed = await process_join_request(
request_id,
int(session["user_id"]),
payload.action == "APPROVE",
company_id,
)
if not changed:
raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/admin/projects")
async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)):
return {
"status": "success",
"projects": await list_company_projects(_require_company_id(session)),
}
@router.put("/projects/{project_id}")
async def dashboard_update_project(
project_id: str,
payload: UpdateProjectRequest,
session: dict[str, Any] = Depends(verify_session),
):
project = await get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if not _can_edit_project(session, project):
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
if not await update_project(project_id, payload.model_dump(), int(session["user_id"])):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return {"status": "success"}
@router.delete("/projects/{project_id}")
async def dashboard_delete_project(
project_id: str,
session: dict[str, Any] = Depends(verify_session),
):
project = await get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
can_del = False
if session["role"] == "SYSTEM_ADMIN":
can_del = True
# 나중에 ADMIN도 소유 프로젝트 삭제 허용할 수 있으므로 주석 처리
# elif session["role"] == "ADMIN":
# if int(project.get("user_id") or 0) == int(session["user_id"]):
# can_del = True
if not can_del:
raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.")
# 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다.
delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project
if not await delete_project(project_id, int(session["user_id"])):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/admin/companies")
async def system_companies(session: dict[str, Any] = Depends(require_system_admin)):
_ = session
return {"status": "success", "companies": await list_all_companies()}
@router.post("/admin/companies")
async def system_create_company(
payload: CreateCompanyRequest,
session: dict[str, Any] = Depends(require_system_admin),
):
result = await create_company(int(session["user_id"]), payload.model_dump())
return {"status": "success", **result}
@router.get("/admin/users")
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
_ = session
return {"status": "success", "users": await list_all_users()}
@router.patch("/admin/users/{user_id}/role")
async def system_change_role(
user_id: int,
payload: ChangeUserRoleRequest,
session: dict[str, Any] = Depends(require_system_admin),
):
target = await get_user_admin_target(user_id)
if not target:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN":
raise HTTPException(
status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다."
)
if not await change_user_role(user_id, payload.role):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
return {"status": "success"}
@router.put("/admin/users/{user_id}")
async def admin_update_user(
user_id: int,
payload: AdminUpdateUserRequest,
session: dict[str, Any] = Depends(verify_session),
):
target = await get_user_admin_target(user_id)
if not target:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
if not _can_edit_user(session, target):
raise HTTPException(status_code=403, detail="사용자 수정 권한이 없습니다.")
data = payload.model_dump()
if session["role"] == "USER":
data["status"] = None
if not await update_admin_user(user_id, data):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
return {"status": "success"}
@router.patch("/admin/users/{user_id}/company")
async def system_assign_company(
user_id: int,
payload: AssignCompanyRequest,
session: dict[str, Any] = Depends(require_system_admin),
):
_ = session
if not await assign_user_company(user_id, payload.company_id):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/admin/join-requests-all")
async def system_join_requests(session: dict[str, Any] = Depends(require_system_admin)):
_ = session
return {"status": "success", "requests": await list_join_requests()}
@router.patch("/admin/join-requests/{request_id}/approve")
async def system_approve_join_request(
request_id: int,
session: dict[str, Any] = Depends(require_system_admin),
):
if not await process_join_request(request_id, int(session["user_id"]), True):
raise HTTPException(status_code=404, detail="처리할 가입 신청을 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/admin/audit-logs")
async def system_audit_logs(
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
session: dict[str, Any] = Depends(require_system_admin),
):
_ = session
return {"status": "success", **await list_audit_logs(limit, (page - 1) * limit)}
@router.get("/admin/resources")
async def system_resources(
days: int = Query(30, ge=1, le=90),
session: dict[str, Any] = Depends(require_system_admin),
):
_ = session
return {"status": "success", **await get_system_resources(days)}
@router.get("/admin/projects-all")
async def system_projects(session: dict[str, Any] = Depends(require_system_admin)):
_ = session
return {"status": "success", "projects": await list_all_projects()}