diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 4c34f3e76..5bc26211c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -54,7 +54,8 @@ function reason(detail: unknown, status: number): string { return lines.filter(Boolean).join(" · ") || `HTTP ${status}`; } -/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) · +/** 지금 화면의 층 — 시스템 · 회사 · 개인 모두 `/layers/{층}` 아래(구조물집계표 · 조합 길 모두 · 읽기는 + * 로그인 누구나 · 쓰기는 서버 층 권한 · 옛 `/api/m02/templates/*` 는 시스템 관리자 전용이라 안 씀) · * `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) — * 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */ let activeLayer: Layer = "system"; @@ -73,24 +74,20 @@ const owned = (layer: Layer, path: string): string => ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}` : path; const scoped = (path: string, init: RequestInit = {}): Promise => - call( - owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`), - init, - ); + call(owned(activeLayer, `/layers/${activeLayer}${path}`), init); const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); -const head = (layer: Layer): string => - layer === "system" ? "/templates" : `/layers/${layer}/templates`; +const head = (layer: Layer): string => `/layers/${layer}/templates`; const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`); -/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */ +/** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */ export const listTemplates = async ( layer: Layer, projectId: string | null, ): Promise => { const got = await call( - layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`), + owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`), ); return Array.isArray(got) ? got : got.양식; }; @@ -114,7 +111,7 @@ export const saveTemplate = ( /** 시스템 층만 지움 */ export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => - call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); + call(`${head("system")}/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); /** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */ export const deleteLayerTemplate = ( @@ -204,7 +201,8 @@ export interface OptionsDoc { 종류: OptionKind[]; } -export const fetchStructureOptions = (): Promise => call("/structure-options"); +export const fetchStructureOptions = (): Promise => + call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자) /** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index ceaf54a23..89fb986df 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -288,6 +288,14 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen return _call(_root(session, layer, owner), store.structure_numbers) +@router.get("/layers/system/structure-options") +def get_structure_options(session: Session = Depends(verify_session)) -> dict: + """구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만).""" + from M02_MasterTemplete import M02_MasterTemplete_Options as options + + return _call(_root(session, "system"), options.listing) + + @router.get("/layers/{layer}/structure-combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.read_combos) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 52667b2fe..639f400c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -276,6 +276,8 @@ async def list_layer( manifest = await asyncio.to_thread(layers.read_manifest, folder) for row in rows: row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") + if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 + row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] return {"층": layer, "양식": rows} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts index 38b03c5f6..ae6ab3e8b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -4,6 +4,7 @@ * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. * ========================================================================== */ +import { t as L } from "@ui/ui_template_locale"; import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; @@ -22,5 +23,6 @@ export async function askCopyFolder( ): Promise { const view = await fetchTree(to.layer, to.owner).catch(() => null); const folders = view ? listFolders(buildFolders(view)) : []; - return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); + const title = to.label.replace("{n}", String(n)); + return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder")); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index ab090480f..73c701f68 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -142,12 +142,14 @@ export function askNewItem(opt: { }); } -/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */ +/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 · + * `fieldLabel` = 칸 글(복사는 「복사할 폴더」) */ export function askFolder( title: string, folders: string[][], blocked: (path: string[]) => boolean, current: string[], + fieldLabel: string = L("M02_TreeMoveTo"), ): Promise { return new Promise((resolve) => { let done = false; @@ -164,7 +166,7 @@ export function askFolder( beforeClose: () => (finish(null), true), mount: (body, close) => { const field = createSelectField({ - label: L("M02_TreeMoveTo"), + label: fieldLabel, options: choices.map((path, i) => ({ value: String(i), text: path.length ? path.join(" / ") : L("M02_TreeTop"), diff --git a/resources/tester/test_m02_system_read.py b/resources/tester/test_m02_system_read.py new file mode 100644 index 000000000..b86aaf039 --- /dev/null +++ b/resources/tester/test_m02_system_read.py @@ -0,0 +1,76 @@ +"""마스터 층 읽기는 층 길 한 벌(39-8 · sub1 검증 흠 H2) — 회사 관리자 · 일반도 `/layers/system/…` 로 +표 · 카드 · 일위대가 · 번호 · 장부 · 옵션을 읽음(200) · 쓰기는 서버가 그대로 막음(403). + +시스템 층 = 견본 표(임시 폴더) · storage = 임시 폴더. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +SYS = "/api/m02/layers/system" +TABLE = f"{SYS}/templates/table/구조물집계표" + + +@pytest.fixture() +def world(tmp_path, monkeypatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.include_router(layers_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session} + + +@pytest.mark.parametrize("role", ["USER", "ADMIN", "SYSTEM_ADMIN"]) +def test_세_계정_모두_마스터_층_읽기_200(world, role): + world["session"]["role"] = role + client = world["client"] + for url in ( + f"{SYS}/templates", + TABLE, + f"{SYS}/templates/structure/rv", + f"{SYS}/templates/labor/rv", + f"{SYS}/templates/area/rv", + f"{SYS}/structures/numbers", + f"{SYS}/structure-combos", + f"{SYS}/structure-options", + ): + assert client.get(url).status_code == 200, url + rows = client.get(f"{SYS}/templates").json()["양식"] + table = next(r for r in rows if (r["종류"], r["이름"]) == ("table", "구조물집계표")) + assert table["쓰는곳"] # 지우기 확인 창 전에 막는 글 + + +@pytest.mark.parametrize("role", ["USER", "ADMIN"]) +def test_회사_관리자_일반은_마스터_쓰기_403(world, role): + world["session"]["role"] = role + client = world["client"] + got = client.get(TABLE).json() + put = client.put(TABLE, json={"판": got["판"], "문서": got["문서"]}) + assert put.status_code == 403 + assert client.post(f"{SYS}/structures/c1").status_code == 403 + assert client.post(f"{SYS}/labor/rv/copy?from=rv").status_code == 403 + gone = client.delete(f"{SYS}/templates/area/rv?판=x") + assert gone.status_code == 403 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 670d97500..78aa7c1c7 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -143,6 +143,7 @@ export const ui_locales_m2 = { M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], M02_TreeMoveTo: ["옮길 폴더", "Move to"], + M02_TreeCopyFolder: ["복사할 폴더", "Copy to folder"], M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"], M02_TreeOk: ["확인", "OK"], M02_TreeCancel: ["취소", "Cancel"],