fix(M02): 마스터 층 읽기도 층 길 한 벌 — 회사 관리자 · 일반 「시스템 관리자 권한」 막힘 풂 (39-8)

- Api_Fetch: 시스템 층도 /layers/system/… (양식 목록 · 읽기 · 저장 · 지우기 · 번호 · 장부 · 새 도면 · 복사) — 옛 /api/m02/templates/* 부름 0
- 서버: GET /layers/system/structure-options(읽기 로그인 누구나 · 고쳐 씀은 옛 길 그대로) · 층 양식 목록에 쓰는곳(지우기 확인 전 막음)
- 「〇〇로 복사…」 폴더 창 칸 글 「복사할 폴더」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-04 21:37:24 +09:00
co-authored by Claude Opus 5.5
parent 46f8b7611d
commit 524dc8febc
7 changed files with 103 additions and 14 deletions
@@ -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 = <T>(path: string, init: RequestInit = {}): Promise<T> =>
call<T>(
owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`),
init,
);
call<T>(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<TemplateInfo[]> => {
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
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<OptionsDoc> => call("/structure-options");
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자)
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
@@ -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)
@@ -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}
@@ -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<string[] | null> {
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"));
}
@@ -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<string[] | null> {
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"),
+76
View File
@@ -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
+1
View File
@@ -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"],