Files
Aislo/B01_Dashboard/B01_Dashboard_Router.py
T
eomsangdonandClaude Opus 5 bca78ee6bd feat(B01): 회사 정보 수정 화면·중복 조회·주소 지도 미리보기
- 회사 정보 수정 API(PUT /dashboard/company) 및 수정 모달 신설, 회사 패널·회사 관리 표에 수정 진입점 추가
- 회사 등록·수정 입력칸 공용화 및 순서 조정 (사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소)
- 회사 등록 시 사업자번호·회사명으로 기존 회사 조회, 있으면 가입 신청으로 유도
- 주소 지도 미리보기 추가 — VWorld 주소검색·배경지도 타일을 서버가 중계(키 노출 방지), 지도 라이브러리 없이 타일 3x3 조합

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 11:29:00 +09:00

632 lines
24 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_email import send_email
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 APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
from .B01_Dashboard_Map import fetch_base_tile, geocode_address
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,
soft_delete_user,
update_admin_user,
update_company,
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 (
attach_company_member,
check_project_refs,
list_company_members,
list_unassigned_users,
remove_company_member,
set_company_logo,
)
from .B01_Dashboard_Schema import (
AddMemberRequest,
AdminUpdateUserRequest,
AssignCompanyRequest,
ChangeUserRoleRequest,
CreateCompanyRequest,
InviteMemberRequest,
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.put("/company")
async def company_update(
payload: CreateCompanyRequest,
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
"""회사 정보 수정 — 시스템관리자만 남의 회사를 지정할 수 있다."""
if not await update_company(_scope_company(session, company_id), payload.model_dump()):
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/company/geocode")
async def company_geocode(
address: str = Query(min_length=2, max_length=500),
session: dict[str, Any] = Depends(verify_session),
):
"""주소를 좌표로 바꾼다 — 회사 등록·수정 화면의 지도 미리보기용."""
_ = session
point = await geocode_address(address)
if not point:
raise HTTPException(status_code=404, detail="주소를 찾지 못했습니다.")
return {"status": "success", **point}
@router.get("/map/tile/{z}/{x}/{y}")
async def map_tile(
z: int,
x: int,
y: int,
session: dict[str, Any] = Depends(verify_session),
):
"""배경지도 타일 — VWorld 키가 화면으로 새지 않게 서버가 받아 넘긴다."""
_ = session
try:
tile = await fetch_base_tile(z, x, y)
except Exception as exc: # 지도 한 칸이 비는 것은 화면을 막을 일이 아니다
raise HTTPException(status_code=502, detail="지도를 불러오지 못했습니다.") from exc
return Response(content=tile, media_type="image/png", headers={"Cache-Control": "max-age=3600"})
@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.get("/admin/members/candidates")
async def admin_member_candidates(
q: str = Query(min_length=2, max_length=100),
session: dict[str, Any] = Depends(require_company_admin),
):
"""팀원으로 부를 수 있는 사람 — 소속이 없는 가입자만 (2026-09-06 사용자 확정)."""
_ = session
return {"status": "success", "users": await list_unassigned_users(q)}
@router.post("/admin/members")
async def admin_add_member(
payload: AddMemberRequest,
session: dict[str, Any] = Depends(require_company_admin),
):
member = await attach_company_member(_require_company_id(session), payload.user_id)
if not member:
raise HTTPException(
status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다."
)
return {"status": "success", "member": member}
@router.post("/admin/members/invite")
async def admin_invite_member(
payload: InviteMemberRequest,
session: dict[str, Any] = Depends(require_company_admin),
):
"""아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다."""
company = await get_user_company(session.get("company_id"))
company_name = (company or {}).get("name") or "회사"
sent = await send_email(
payload.email,
f"[Aislo] {company_name} 팀원 등록 안내",
f"<p>{payload.name or ''}님, {company_name} 에서 Aislo 팀원으로 등록하려 합니다.</p>"
f"<p>아래 주소에서 가입한 뒤 알려 주시면 회사 관리자가 팀원으로 등록합니다.</p>"
f'<p><a href="{APP_PUBLIC_BASE_URL}">{APP_PUBLIC_BASE_URL}</a></p>',
)
if not sent:
raise HTTPException(status_code=502, detail="안내 메일을 보내지 못했습니다.")
return {"status": "success"}
@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.delete("/admin/users/{user_id}")
async def admin_delete_user(
user_id: int,
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="사용자를 찾을 수 없습니다.")
if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")):
raise HTTPException(status_code=403, detail="다른 회사 사용자는 지울 수 없습니다.")
if int(session["user_id"]) == user_id:
raise HTTPException(status_code=409, detail="본인 계정은 지울 수 없습니다.")
# 회사에 관리자가 하나도 남지 않게 되는 삭제는 막는다.
if (
target["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 soft_delete_user(user_id):
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)