Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -360,16 +360,23 @@ export async function searchMemberCandidates(query: string): Promise<Member[]> {
|
||||
}
|
||||
|
||||
/** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */
|
||||
export function addCompanyMember(userId: number): Promise<{ member: Member }> {
|
||||
return request("/dashboard/admin/members", {
|
||||
export function addCompanyMember(
|
||||
userId: number,
|
||||
companyId?: number | null,
|
||||
): Promise<{ member: Member }> {
|
||||
return request(`/dashboard/admin/members${companyQuery(companyId)}`, {
|
||||
method: "POST",
|
||||
body: body({ user_id: userId }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */
|
||||
export function inviteMember(email: string, name?: string | null): Promise<unknown> {
|
||||
return request("/dashboard/admin/members/invite", {
|
||||
export function inviteMember(
|
||||
email: string,
|
||||
name?: string | null,
|
||||
companyId?: number | null,
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/admin/members/invite${companyQuery(companyId)}`, {
|
||||
method: "POST",
|
||||
body: body({ email, name: name || null }),
|
||||
});
|
||||
@@ -395,8 +402,11 @@ export function removeCompanyMember(userId: number): Promise<unknown> {
|
||||
return request(`/dashboard/admin/members/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchCompanyJoinRequests(): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests");
|
||||
/** companyId 는 시스템관리자가 대시보드에서 고른 회사(29-1) — 없으면 자기 회사. */
|
||||
export async function fetchCompanyJoinRequests(companyId?: number | null): Promise<JoinRequest[]> {
|
||||
const data = await request<{ requests: JoinRequest[] }>(
|
||||
`/dashboard/admin/join-requests${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.requests;
|
||||
}
|
||||
|
||||
@@ -410,8 +420,10 @@ export function processJoinRequest(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyProjects(): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects");
|
||||
export async function fetchCompanyProjects(companyId?: number | null): Promise<ProjectItem[]> {
|
||||
const data = await request<{ projects: ProjectItem[] }>(
|
||||
`/dashboard/admin/projects${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
@@ -425,8 +437,10 @@ export async function fetchAllCompanies(): Promise<CompanyInfo[]> {
|
||||
return data.companies;
|
||||
}
|
||||
|
||||
export async function fetchAllUsers(): Promise<DashboardUser[]> {
|
||||
const data = await request<{ users: DashboardUser[] }>("/dashboard/admin/users");
|
||||
export async function fetchAllUsers(companyId?: number | null): Promise<DashboardUser[]> {
|
||||
const data = await request<{ users: DashboardUser[] }>(
|
||||
`/dashboard/admin/users${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.users;
|
||||
}
|
||||
|
||||
|
||||
@@ -338,15 +338,17 @@ async def soft_delete_project(project_id: str, actor_id: int, request: Any | Non
|
||||
return changed
|
||||
|
||||
|
||||
async def list_all_users() -> list[dict[str, Any]]:
|
||||
async def list_all_users(company_id: int | None = None) -> list[dict[str, Any]]:
|
||||
where = " AND u.company_id = %s" if company_id else ""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
|
||||
f"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone,
|
||||
u.role, u.status, u.company_id,
|
||||
c.name AS company_name, u.last_login
|
||||
FROM users u LEFT JOIN companies c ON c.id = u.company_id
|
||||
WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC LIMIT 200"""
|
||||
WHERE u.deleted_at IS NULL{where} ORDER BY u.created_at DESC LIMIT 200""",
|
||||
(company_id,) if company_id else (),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
for row in rows:
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
"""B01_Dashboard 역할별 대시보드 API."""
|
||||
|
||||
import html
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response
|
||||
from pymysql.err import IntegrityError
|
||||
|
||||
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, search_address
|
||||
@@ -47,17 +33,6 @@ from .B01_Dashboard_Repository import (
|
||||
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_Company import (
|
||||
create_company,
|
||||
get_user_company,
|
||||
@@ -75,9 +50,9 @@ from .B01_Dashboard_Repository_Members import (
|
||||
list_company_members,
|
||||
list_unassigned_users,
|
||||
remove_company_member,
|
||||
set_company_logo,
|
||||
set_project_members,
|
||||
)
|
||||
from .B01_Dashboard_Router_Assets import router as assets_router
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AdminUpdateUserRequest,
|
||||
@@ -88,52 +63,19 @@ from .B01_Dashboard_Schema import (
|
||||
JoinCompanyRequest,
|
||||
ProcessJoinRequest,
|
||||
UiPrefsRequest,
|
||||
UpdateCompanyAssetRequest,
|
||||
UpdateCompanyLogoRequest,
|
||||
UpdateProjectRequest,
|
||||
UpdateUserRequest,
|
||||
)
|
||||
from .B01_Dashboard_Scope import (
|
||||
_require_company_id,
|
||||
_same_company,
|
||||
_scope_company,
|
||||
require_company_admin,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||
if session["role"] == "SYSTEM_ADMIN":
|
||||
return True
|
||||
@@ -326,9 +268,10 @@ async def admin_member_candidates(
|
||||
@router.post("/admin/members")
|
||||
async def admin_add_member(
|
||||
payload: AddMemberRequest,
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
member = await attach_company_member(_require_company_id(session), payload.user_id)
|
||||
member = await attach_company_member(_scope_company(session, company_id), payload.user_id)
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다."
|
||||
@@ -339,10 +282,11 @@ async def admin_add_member(
|
||||
@router.post("/admin/members/invite")
|
||||
async def admin_invite_member(
|
||||
payload: InviteMemberRequest,
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
"""아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다."""
|
||||
company = await get_user_company(session.get("company_id"))
|
||||
company = await get_user_company(_scope_company(session, company_id))
|
||||
company_name = (company or {}).get("name") or "회사"
|
||||
# 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다.
|
||||
safe_company = html.escape(company_name)
|
||||
@@ -369,8 +313,12 @@ async def admin_remove_member(
|
||||
|
||||
|
||||
@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))}
|
||||
async def admin_join_requests(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
scoped = _scope_company(session, company_id)
|
||||
return {"status": "success", "requests": await list_join_requests(scoped)}
|
||||
|
||||
|
||||
@router.patch("/admin/join-requests/{request_id}")
|
||||
@@ -392,10 +340,13 @@ async def admin_process_join_request(
|
||||
|
||||
|
||||
@router.get("/admin/projects")
|
||||
async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
async def admin_projects(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
return {
|
||||
"status": "success",
|
||||
"projects": await list_company_projects(_require_company_id(session)),
|
||||
"projects": await list_company_projects(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@@ -470,9 +421,13 @@ async def system_create_company(
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
async def system_users(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_system_admin),
|
||||
):
|
||||
"""company_id = 대시보드에서 고른 회사 사람만(29-1) · 없으면 전체."""
|
||||
_ = session
|
||||
return {"status": "success", "users": await list_all_users()}
|
||||
return {"status": "success", "users": await list_all_users(company_id)}
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}/role")
|
||||
@@ -607,95 +562,4 @@ async def system_projects(session: dict[str, Any] = Depends(require_system_admin
|
||||
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)
|
||||
router.include_router(assets_router)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""B01_Dashboard 회사 공유 도면 자산(로고·서명) API — 본체 라우터가 include 함."""
|
||||
|
||||
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 verify_session
|
||||
from common_util.common_util_storage import read_stored_asset
|
||||
|
||||
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 list_company_members, set_company_logo
|
||||
from .B01_Dashboard_Schema import UpdateCompanyAssetRequest, UpdateCompanyLogoRequest
|
||||
from .B01_Dashboard_Scope import _scope_company, require_company_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
|
||||
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""B01_Dashboard 권한 · 회사 범위 도우미 — 라우터 둘(본체 · 자산)이 함께 씀."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
|
||||
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
|
||||
@@ -584,7 +584,10 @@ export function openFindCompanyModal(): void {
|
||||
* (2026-09-06 사용자 확정). 계정을 대신 만들지 않는다. 아직 가입하지 않은 사람에게는
|
||||
* 안내 메일만 보낸다.
|
||||
*/
|
||||
export function openAddMemberModal(onCreated?: (member: Member) => void): void {
|
||||
export function openAddMemberModal(
|
||||
onCreated?: (member: Member) => void,
|
||||
companyId?: number | null,
|
||||
): void {
|
||||
const query = createInputField({
|
||||
label: "이름 또는 이메일로 찾기",
|
||||
placeholder: "두 글자 이상",
|
||||
@@ -638,7 +641,7 @@ export function openAddMemberModal(onCreated?: (member: Member) => void): void {
|
||||
return;
|
||||
}
|
||||
query.setError();
|
||||
await inviteMember(text);
|
||||
await inviteMember(text, null, companyId);
|
||||
showToast("안내 메일을 보냈습니다.", "success");
|
||||
},
|
||||
});
|
||||
@@ -652,7 +655,7 @@ export function openAddMemberModal(onCreated?: (member: Member) => void): void {
|
||||
query.setError("등록할 사람을 먼저 고르십시오.");
|
||||
throw new Error("등록할 사람을 먼저 고르십시오.");
|
||||
}
|
||||
const created = await addCompanyMember(picked.id);
|
||||
const created = await addCompanyMember(picked.id, companyId);
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
if (created?.member) onCreated?.(created.member);
|
||||
});
|
||||
|
||||
@@ -7,13 +7,12 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createMapContextMenu, type MapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
fetchAllJoinRequests,
|
||||
fetchAllProjects,
|
||||
fetchAllUsers,
|
||||
fetchAuditLogs,
|
||||
fetchCompanyJoinRequests,
|
||||
@@ -59,6 +58,18 @@ export interface DashboardState {
|
||||
allJoinRequests: JoinRequest[];
|
||||
auditLogs: AuditLog[];
|
||||
resources: ResourceData | null;
|
||||
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
|
||||
adminCompanyId: number | null;
|
||||
}
|
||||
|
||||
/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */
|
||||
const ADMIN_COMPANY_KEY = "aislo.adminCompany";
|
||||
|
||||
/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */
|
||||
function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null {
|
||||
const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY));
|
||||
if (companies.some((c) => c.id === saved)) return saved;
|
||||
return user.company_id ?? companies[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
@@ -78,6 +89,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
allJoinRequests: [],
|
||||
auditLogs: [],
|
||||
resources: null,
|
||||
adminCompanyId: null,
|
||||
};
|
||||
await loadRoleData(state);
|
||||
root.innerHTML = "";
|
||||
@@ -91,20 +103,22 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
|
||||
async function loadRoleData(state: DashboardState): Promise<void> {
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
// 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1)
|
||||
state.allCompanies = await fetchAllCompanies().catch(() => []);
|
||||
const companyId = pickAdminCompany(state.user, state.allCompanies);
|
||||
state.adminCompanyId = companyId;
|
||||
const results = await Promise.allSettled([
|
||||
fetchAllCompanies(),
|
||||
fetchAllUsers(),
|
||||
fetchAllJoinRequests(),
|
||||
fetchAllUsers(companyId),
|
||||
fetchCompanyJoinRequests(companyId),
|
||||
fetchAuditLogs(),
|
||||
fetchSystemResources(7),
|
||||
fetchAllProjects(),
|
||||
fetchCompanyProjects(companyId),
|
||||
]);
|
||||
state.allCompanies = results[0].status === "fulfilled" ? results[0].value : [];
|
||||
state.allUsers = results[1].status === "fulfilled" ? results[1].value : [];
|
||||
state.allJoinRequests = results[2].status === "fulfilled" ? results[2].value : [];
|
||||
state.auditLogs = results[3].status === "fulfilled" ? results[3].value : [];
|
||||
state.resources = results[4].status === "fulfilled" ? results[4].value : null;
|
||||
state.allProjects = results[5].status === "fulfilled" ? results[5].value : [];
|
||||
state.allUsers = results[0].status === "fulfilled" ? results[0].value : [];
|
||||
state.allJoinRequests = results[1].status === "fulfilled" ? results[1].value : [];
|
||||
state.auditLogs = results[2].status === "fulfilled" ? results[2].value : [];
|
||||
state.resources = results[3].status === "fulfilled" ? results[3].value : null;
|
||||
state.allProjects = results[4].status === "fulfilled" ? results[4].value : [];
|
||||
|
||||
results.forEach((res, idx) => {
|
||||
if (res.status === "rejected") {
|
||||
@@ -148,11 +162,16 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement {
|
||||
}
|
||||
|
||||
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
|
||||
function buildSystemSettingsPanel(user: DashboardUser): HTMLElement {
|
||||
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__setting-grid";
|
||||
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
|
||||
const company: Record<string, string> =
|
||||
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
|
||||
const m02 = (title: string, open: string, root: string, desc: string): HTMLElement =>
|
||||
settingCard(title, desc, () => navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root }));
|
||||
settingCard(title, desc, () =>
|
||||
navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }),
|
||||
);
|
||||
if (user.role === "SYSTEM_ADMIN") {
|
||||
const desc = L("B01_Dashboard_PermMasterWrite");
|
||||
wrap.append(
|
||||
@@ -181,9 +200,46 @@ function buildSystemSettingsPanel(user: DashboardUser): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 회사 메뉴 — 공용 작은 메뉴 하나를 알약 모두가 같이 씀 */
|
||||
let companyMenu: MapContextMenu | null = null;
|
||||
|
||||
/** 회사 알약(시스템 관리자만) — 누르면 등록된 회사 메뉴 · 고르면 저장 뒤 대시보드를 다시 읽음 */
|
||||
function companyPill(state: DashboardState): HTMLElement {
|
||||
const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-";
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "ui-tag ui-tag--accent b01-dashboard__company-pill";
|
||||
pill.title = L("B01_Dashboard_PickCompany");
|
||||
pill.textContent = `${name} ▾`;
|
||||
pill.addEventListener("click", (event) => {
|
||||
// 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음
|
||||
event.stopPropagation();
|
||||
if (!companyMenu?.element.isConnected) {
|
||||
companyMenu = createMapContextMenu("b01-dashboard");
|
||||
document.body.append(companyMenu.element);
|
||||
}
|
||||
const box = pill.getBoundingClientRect();
|
||||
companyMenu.open(
|
||||
box.left,
|
||||
box.bottom + 4,
|
||||
state.allCompanies.map((c): [string, () => void] => [
|
||||
c.name,
|
||||
() => {
|
||||
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id));
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
return pill;
|
||||
}
|
||||
|
||||
/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */
|
||||
function buildSystemSettingsSection(user: DashboardUser): HTMLElement {
|
||||
const card = section(L("B01_Dashboard_SystemSettings"), buildSystemSettingsPanel(user), true);
|
||||
function buildSystemSettingsSection(state: DashboardState): HTMLElement {
|
||||
const { user } = state;
|
||||
const pill = user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : [];
|
||||
const panel = buildSystemSettingsPanel(user, state.adminCompanyId);
|
||||
const card = section(L("B01_Dashboard_SystemSettings"), panel, true, pill);
|
||||
card.querySelector(".ui-card__body")?.classList.add("ui-collapsible");
|
||||
card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title");
|
||||
attachCollapsible(card);
|
||||
@@ -196,26 +252,40 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__grid";
|
||||
// 시스템 설정은 모든 역할 맨 위 한 줄(27-3a)
|
||||
grid.append(buildSystemSettingsSection(state.user));
|
||||
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
// 시스템 설정은 리소스 현황 아래(29-1) · 회사 알약이 붙은 컨테이너는 고른 회사 것
|
||||
const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId);
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
|
||||
buildSystemSettingsSection(state),
|
||||
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
|
||||
companyPill(state),
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
buildTempUploadSection(),
|
||||
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||
companyPill(state),
|
||||
createButton({
|
||||
label: "+",
|
||||
onClick: () => openAddMemberModal(undefined, state.adminCompanyId),
|
||||
}),
|
||||
]),
|
||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true),
|
||||
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [
|
||||
section(
|
||||
L("B01_Dashboard_JoinRequests"),
|
||||
joinRequestTable(state.allJoinRequests, true),
|
||||
true,
|
||||
[companyPill(state)],
|
||||
),
|
||||
section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [
|
||||
companyPill(state),
|
||||
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
|
||||
]),
|
||||
);
|
||||
} else if (state.user.role === "ADMIN") {
|
||||
// 시스템 설정은 맨 위 한 줄(27-3a)
|
||||
grid.append(
|
||||
buildSystemSettingsSection(state),
|
||||
section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
@@ -228,6 +298,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
);
|
||||
} else {
|
||||
grid.append(
|
||||
buildSystemSettingsSection(state),
|
||||
section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
|
||||
@@ -68,6 +68,59 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 회사 알약 — 회사를 따라 바뀌는 컨테이너 제목 오른쪽 · 누르면 회사 메뉴 */
|
||||
.b01-dashboard__company-pill {
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__context-menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.b01-dashboard__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__context-menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-cards);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
/* 접히는 제목 줄 — 단추 칸을 접힘 표시 바로 왼쪽으로 */
|
||||
.ui-collapsible__title > .b01-dashboard__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__company-pick .ui-field__label {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""B01 대시보드 「시스템 설정」 컨테이너 시험(27-3) — 화면 조작은 ORCA.
|
||||
|
||||
· 역할별 카드(제목 · 설명 · 이동 해시)를 Node 로 바로 돌림 — 가짜 DOM
|
||||
· 모든 역할 맨 위 한 줄 · 통째로 접힘 · 「구조물 관리」 컨테이너 없음
|
||||
· 모든 역할 한 줄 · 통째로 접힘 · 「구조물 관리」 컨테이너 없음
|
||||
· 회사 고르기(29-1) — 시스템 관리자만 · 리소스 현황 아래 · 해시 company · 서버 인자 한 곳
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,16 +29,18 @@ def _fn(src: str, name: str) -> str:
|
||||
return found.group(0)
|
||||
|
||||
|
||||
def _cards(user: dict) -> list:
|
||||
def _cards(user: dict, company_id: int | None = None) -> list:
|
||||
src = _text(PAGE)
|
||||
script = (
|
||||
"interface DashboardUser { role: string; is_master: boolean }\n"
|
||||
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data', M02_MASTER_TEMPLATE: 'm02-master-template' };\n"
|
||||
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data',"
|
||||
" M02_MASTER_TEMPLATE: 'm02-master-template' };\n"
|
||||
"const L = (k: string) => k;\n"
|
||||
"let went = '';\n"
|
||||
"const navigateTo = (r: string, q?: Record<string, string>) =>"
|
||||
" { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n"
|
||||
"const mk = (): any => ({ children: [] as any[], textContent: '', className: '', on: null as any,"
|
||||
"const mk = (): any => ({ children: [] as any[], textContent: '', className: '',"
|
||||
" on: null as any,"
|
||||
" append(...c: any[]) { this.children.push(...c); },"
|
||||
" addEventListener(_: string, f: any) { this.on = f; } });\n"
|
||||
"const document = { createElement: () => mk() };\n"
|
||||
@@ -45,13 +48,14 @@ def _cards(user: dict) -> list:
|
||||
+ "\n"
|
||||
+ _fn(src, "buildSystemSettingsPanel")
|
||||
+ "\nconst user = JSON.parse(process.argv[1]);"
|
||||
+ "\nconsole.log(JSON.stringify(buildSystemSettingsPanel(user).children.map((c: any) => {"
|
||||
+ "\nconst panel = buildSystemSettingsPanel(user, JSON.parse(process.argv[2]));"
|
||||
+ "\nconsole.log(JSON.stringify(panel.children.map((c: any) => {"
|
||||
+ " c.on(); return [c.children[0].textContent, c.children[1].textContent,"
|
||||
+ " decodeURIComponent(went)]; })));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(user)],
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(user), json.dumps(company_id)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -66,15 +70,19 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_시스템_관리자_카드_다섯():
|
||||
def test_시스템_관리자_카드_다섯_고른_회사_해시():
|
||||
m = "B01_Dashboard_PermMasterWrite"
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}) == [
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
|
||||
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
|
||||
["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master"],
|
||||
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master"],
|
||||
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master"],
|
||||
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master"],
|
||||
["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master&company=3"],
|
||||
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
|
||||
["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"],
|
||||
["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
|
||||
]
|
||||
# 회사가 없으면 해시에 안 실음
|
||||
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == (
|
||||
f"{M02}?open=일위대가&root=master"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
@@ -83,7 +91,7 @@ def test_회사_관리자_개인_카드_둘():
|
||||
["B01_Dashboard_CardUnitCombo", WRITE_CO, f"{M02}?open=일위대가&root=company"],
|
||||
["B01_Dashboard_CardStructCombo", WRITE_CO, f"{M02}?open=구조물조합&root=company"],
|
||||
]
|
||||
assert _cards({"role": "ADMIN", "is_master": False}) == company
|
||||
assert _cards({"role": "ADMIN", "is_master": False}, 3) == company # 회사 인자 안 실음
|
||||
assert _cards({"role": "USER", "is_master": True}) == company # 회사 마스터 = 회사 수정
|
||||
assert _cards({"role": "USER", "is_master": False}) == [
|
||||
["B01_Dashboard_CardUnitCombo", READ_CO, f"{M02}?open=일위대가&root=personal"],
|
||||
@@ -91,18 +99,91 @@ def test_회사_관리자_개인_카드_둘():
|
||||
]
|
||||
|
||||
|
||||
def test_모든_역할_맨_위_통째로_접힘():
|
||||
def test_모든_역할_한_줄_통째로_접힘():
|
||||
src = _text(PAGE)
|
||||
build = _fn(src, "buildPage")
|
||||
# 역할 갈래 앞에서 한 번 — 모든 역할 · 프로젝트 위
|
||||
assert build.index("grid.append(buildSystemSettingsSection(state.user))") < build.index(
|
||||
'if (state.user.role === "SYSTEM_ADMIN")'
|
||||
# 세 갈래 모두 한 번씩 · 시스템 관리자는 리소스 현황 아래(29-1) · 프로젝트 위
|
||||
assert build.count("buildSystemSettingsSection(state)") == 3
|
||||
sys_part = build.split('} else if (state.user.role === "ADMIN")')[0]
|
||||
assert (
|
||||
sys_part.index('L("B01_Dashboard_Resources")')
|
||||
< sys_part.index("buildSystemSettingsSection(state)")
|
||||
< sys_part.index('L("B01_Dashboard_Projects")')
|
||||
)
|
||||
assert "StructureManage" not in src and "buildStructurePanel" not in src
|
||||
assert "top-row" not in src
|
||||
sect = _fn(src, "buildSystemSettingsSection")
|
||||
assert "buildSystemSettingsPanel(user), true)" in sect # 한 줄 통째
|
||||
assert 'L("B01_Dashboard_SystemSettings"), panel, true, pill)' in sect # 한 줄 통째
|
||||
assert '"ui-collapsible"' in sect and '"ui-collapsible__title"' in sect
|
||||
assert "attachCollapsible(card)" in sect
|
||||
config = _text(ROOT / "config" / "config_frontend.ts")
|
||||
assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config)
|
||||
|
||||
|
||||
def test_회사_알약_시스템_관리자만():
|
||||
src = _text(PAGE)
|
||||
sect = _fn(src, "buildSystemSettingsSection")
|
||||
assert 'user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : []' in sect
|
||||
pill = _fn(src, "companyPill")
|
||||
assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id))" in pill
|
||||
assert "event.stopPropagation()" in pill # 알약 누름이 접힘으로 새지 않음
|
||||
assert 'createMapContextMenu("b01-dashboard")' in pill # 공용 작은 메뉴
|
||||
assert '"ui-tag ui-tag--accent b01-dashboard__company-pill"' in pill # 공용 알약 모양
|
||||
assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키
|
||||
load = src.split("async function loadRoleData")[1].split("\n}\n")[0]
|
||||
for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)",
|
||||
"fetchCompanyProjects(companyId)"): # fmt: skip
|
||||
assert call in load, call
|
||||
build = _fn(src, "buildPage")
|
||||
# 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약
|
||||
sys_part = build.split('} else if (state.user.role === "ADMIN")')[0]
|
||||
assert sys_part.count("companyPill(state)") == 4
|
||||
assert "companyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1]
|
||||
assert "companyTable(picked, state.user)" in build
|
||||
assert "openAddMemberModal(undefined, state.adminCompanyId)" in build
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_고른_회사_저장값_검사():
|
||||
src = _text(PAGE)
|
||||
script = (
|
||||
"interface DashboardUser { company_id?: number | null }\n"
|
||||
"interface CompanyInfo { id: number }\n"
|
||||
"const ADMIN_COMPANY_KEY = 'aislo.adminCompany';\n"
|
||||
"const store: Record<string, string> = {};\n"
|
||||
"const sessionStorage = { getItem: (k: string) => store[k] ?? null };\n"
|
||||
+ _fn(src, "pickAdminCompany")
|
||||
+ "\nconst cs = [{ id: 1 }, { id: 3 }];"
|
||||
+ "\nconst out = [pickAdminCompany({ company_id: 1 }, cs)];"
|
||||
+ "\nstore[ADMIN_COMPANY_KEY] = '3'; out.push(pickAdminCompany({ company_id: 1 }, cs));"
|
||||
+ "\nstore[ADMIN_COMPANY_KEY] = '9'; out.push(pickAdminCompany({ company_id: 1 }, cs));"
|
||||
+ "\nout.push(pickAdminCompany({ company_id: null }, cs));"
|
||||
+ "\nconsole.log(JSON.stringify(out));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
# 기본 자기 회사 · 고른 회사 · 없는 회사면 자기 회사 · 자기 회사 없으면 첫 회사
|
||||
assert json.loads(result.stdout) == [1, 3, 1, 1]
|
||||
|
||||
|
||||
def test_서버_회사_인자_한_곳():
|
||||
scope = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_Scope.py")
|
||||
assert 'if session["role"] == "SYSTEM_ADMIN" and company_id is not None:' in scope
|
||||
router = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_Router.py")
|
||||
for name in (
|
||||
"admin_add_member",
|
||||
"admin_invite_member",
|
||||
"admin_join_requests",
|
||||
"admin_projects",
|
||||
):
|
||||
body = router.split(f"async def {name}(")[1].split("@router.")[0]
|
||||
assert "company_id: int | None = Query(None, gt=0)" in body, name
|
||||
assert "_scope_company(session, company_id)" in body, name
|
||||
users = router.split("async def system_users(")[1].split("@router.")[0]
|
||||
assert "require_system_admin" in users and "list_all_users(company_id)" in users
|
||||
assert "router.include_router(assets_router)" in router
|
||||
assert len(router.splitlines()) <= 700
|
||||
|
||||
@@ -145,6 +145,7 @@ export const ui_locales_b3 = {
|
||||
B01_Dashboard_PermCompanyRead: ["회사 읽기", "Company read"],
|
||||
B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"],
|
||||
B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"],
|
||||
B01_Dashboard_PickCompany: ["회사 고르기", "Pick company"],
|
||||
|
||||
/* --- B08·B09 빈 화면 (PLAN 7-3) --- */
|
||||
B08_Quantity_Rebuilding: [
|
||||
|
||||
Reference in New Issue
Block a user