"""B01_Dashboard 역할별 대시보드 API.""" import mimetypes import os from typing import Any from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile from common_util.common_util_auth import require_company, require_system_admin, verify_session from common_util.common_util_project_delete import hard_delete_project from common_util.common_util_storage import read_stored_asset from config.config_system import PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Repository import ( assign_user_company, change_user_role, count_company_admins, 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_projects, list_join_requests, list_user_projects, process_join_request, search_companies, soft_delete_project, update_admin_user, update_project, update_user_profile, ) from .B01_Dashboard_Repository_Assets import ( ASSET_EXTENSIONS, ASSET_KINDS, ASSET_MAX_BYTES, create_company_asset, delete_company_asset, get_company_asset, list_company_assets, update_company_asset, write_company_asset_file, ) from .B01_Dashboard_Repository_Members import ( add_company_member, check_project_refs, list_company_members, remove_company_member, set_company_logo, ) from .B01_Dashboard_Schema import ( AddMemberRequest, AdminUpdateUserRequest, AssignCompanyRequest, ChangeUserRoleRequest, CreateCompanyRequest, JoinCompanyRequest, ProcessJoinRequest, UpdateCompanyAssetRequest, UpdateCompanyLogoRequest, 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 _scope_company(session: dict[str, Any], company_id: int | None) -> int: """회사 범위 자원(구성원·로고·서명)의 회사. 시스템관리자만 남의 회사를 지정할 수 있다.""" if session["role"] == "SYSTEM_ADMIN" and company_id is not None: return int(company_id) own = _require_company_id(session) if company_id is not None and int(company_id) != own: raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.") return own async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]: asset = await get_company_asset(asset_id) if not asset: raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.") _scope_company(session, int(asset["company_id"])) return asset 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)): # 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다. company_id = session.get("company_id") projects = ( await list_company_projects(int(company_id)) if company_id else await list_user_projects(int(session["user_id"])) ) return {"status": "success", "projects": projects} @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) if result is None: raise HTTPException( status_code=409, detail="이미 회사에 소속돼 있습니다. 소속을 옮기려면 회사 관리자에게 요청하십시오.", ) return {"status": "success", **result} @router.get("/admin/members") async def admin_members( company_id: int | None = Query(None, gt=0), session: dict[str, Any] = Depends(require_company), ): # 읽기는 회사 구성원 누구나 — B02 등록 화면에서 일반 사용자도 담당자를 골라야 한다 # (2026-09-02 사용자 지시). 쓰기(POST·DELETE)는 관리자 그대로다. # company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다. return { "status": "success", "members": await list_company_members(_scope_company(session, company_id)), } @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, { "name": payload.name, "position": payload.position, "department": payload.department, }, ) 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="프로젝트 수정 권한이 없습니다.") data = payload.model_dump() # 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시). start_m, end_m = data.get("route_start_m"), data.get("route_end_m") if start_m is not None and end_m is not None and start_m >= end_m: raise HTTPException( status_code=400, detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", ) await check_project_refs(int(project["company_id"]), data) if not await update_project(project_id, data, 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="프로젝트를 찾을 수 없습니다.") # 회사 관리자는 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). can_del = session["role"] == "SYSTEM_ADMIN" or ( session["role"] == "ADMIN" and _same_company(session, project.get("company_id")) ) 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_company_admin), ): target = await get_user_admin_target(user_id) if not target: raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") # 회사 관리자는 자기 회사 사람만 (2026-09-06 사용자 확정). 시스템관리자는 전역이다. if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")): raise HTTPException(status_code=403, detail="다른 회사 사용자는 바꿀 수 없습니다.") if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN": raise HTTPException( status_code=403, detail="시스템 관리자 역할은 시스템 관리 회사 소속 여부로 정해집니다.", ) # 회사에 관리자가 하나도 남지 않게 되는 강등은 막는다 (본인 강등 포함). if ( target["role"] == "ADMIN" and payload.role != "ADMIN" and target.get("company_id") and await count_company_admins(int(target["company_id"])) <= 1 ): raise HTTPException( status_code=409, detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.", ) 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()} # ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ---- @router.get("/company/assets") async def company_assets( company_id: int | None = Query(None, gt=0), session: dict[str, Any] = Depends(verify_session), ): return { "status": "success", "assets": await list_company_assets(_scope_company(session, company_id)), } @router.post("/company/assets") async def company_add_asset( kind: str = Form(...), label: str = Form(...), user_id: int | None = Form(None, gt=0), company_id: int | None = Form(None, gt=0), file: UploadFile = File(...), session: dict[str, Any] = Depends(verify_session), ): kind = kind.upper() if kind not in ASSET_KINDS: raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.") label = label.strip()[:100] if not label: raise HTTPException(status_code=400, detail="이름을 넣어 주세요.") suffix = os.path.splitext(file.filename or "")[1].lower() if suffix not in ASSET_EXTENSIONS: raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.") blob = await file.read() if not blob or len(blob) > ASSET_MAX_BYTES: raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.") scoped = _scope_company(session, company_id) if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}: raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.") path = write_company_asset_file(scoped, kind, suffix, blob) asset_id = await create_company_asset( scoped, kind, label, path, user_id, int(session["user_id"]) ) return {"status": "success", "asset_id": asset_id} @router.put("/company/assets/{asset_id}") async def company_update_asset( asset_id: int, payload: UpdateCompanyAssetRequest, session: dict[str, Any] = Depends(verify_session), ): await _company_asset(session, asset_id) await update_company_asset(asset_id, payload.label.strip(), payload.user_id) return {"status": "success"} @router.delete("/company/assets/{asset_id}") async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)): await _company_asset(session, asset_id) await delete_company_asset(asset_id) return {"status": "success"} @router.put("/company/logo") async def company_set_logo( payload: UpdateCompanyLogoRequest, company_id: int | None = Query(None, gt=0), session: dict[str, Any] = Depends(require_company_admin), ): """회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정). 프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다. """ scoped = _scope_company(session, company_id) if payload.logo_asset_id is not None: asset = await get_company_asset(payload.logo_asset_id) if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO": raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.") if not await set_company_logo(scoped, payload.logo_asset_id): raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") return {"status": "success"} @router.get("/company/assets/{asset_id}/file") async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)): """목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다.""" asset = await _company_asset(session, asset_id) blob = read_stored_asset(asset["file_path"]) if blob is None: raise HTTPException(status_code=404, detail="그림 파일이 없습니다.") media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream" return Response(content=blob, media_type=media_type)