사용자 지시(2026-09-02) — 프로젝트 등록에서도 수정 모달과 같은 항목 수신. - 시행청·연도기번·사업량·설계일자·과업책임자·분야별책임자·설계자·회사 로고 8칸 추가. 담당자는 select + 「신규 등록…」(관리자만), 로고는 회사 대표 로고가 기본값 - 담당자·자산 회사 경계 검사를 check_project_refs(company_id, data) 로 옮겨 B01 수정과 공용 - GET /admin/members 문턱을 require_company 로 — 일반 사용자도 담당자 선택 필요. _scope_company 가 남의 회사를 막고 쓰기는 관리자 그대로 - fix: road_type 스키마가 옛 값(branch·stream)을 받고 화면 선택지 work 를 막아 「작업임도」 등록이 422 로 떨어지던 것 수정 — ^(main|fire|work)$ - 검증(5174 실조작): 등록 칸 14개로 수정 모달과 동일, 등록된 행에 8칸 그대로 저장 (road_type=work, logo_asset_id=1, designer=3), 해당 프로젝트 표제란 12칸 전부 채워짐. pytest 151 passed (신규 test_b02_register_schema.py 8건) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
503 lines
18 KiB
Python
503 lines
18 KiB
Python
"""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,
|
|
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)):
|
|
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(
|
|
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()
|
|
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="프로젝트를 찾을 수 없습니다.")
|
|
|
|
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()}
|
|
|
|
|
|
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
|
|
|
|
|
|
@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)
|