From 59cb28ce588523f773296b30c1f19973d409c9c6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:19:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=ED=95=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=C2=B7=20=EB=BF=8C=EB=A6=AC=20=EC=85=8B=20?= =?UTF-8?q?=C2=B7=20=EC=B8=B5=20=EA=B6=8C=ED=95=9C=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=20=C2=B7=20=EB=82=A8=EC=9D=98=20=EC=B8=B5=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=C2=B7=20=ED=95=B4=EC=8B=9C=20=EC=A7=84=EC=9E=85=20(PLAN=202?= =?UTF-8?q?7-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 층 권한 한 벌 M02_Layer_Access — 나무 · 항목(Router_Items) · 양식(Router_Layers) 같은 규칙 · owner 인자 · 남의 층은 읽기만 · 읽기로 남의 층에 뼈대 안 씀 - 남의 층 목록 GET /api/m02/layers/{company|personal}/owners — 시스템 관리자 회사 목록 · 회사 관리자 같은 회사 사람 - ?layer= 페이지 분리 폐기 → 한 화면 뿌리 셋(마스터 · 회사 · 개인) · 뿌리 부품 TreePanel_Roots(펼칠 때 불러옴) · TreePanel 은 owner · api 를 받는 공용 부품 - 진입 해시 ?open=구조물조합|일위대가|옵션|도면&root=&owner= · 항목 고르기 [관리 화면에서 고치기]도 이 해시 - 「항목 나무」 → 「구조물 라이브러리」 - 시험 test_m02_layer_access 새로 · layer_screen · template_layers 새 규칙으로 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Layer_Access.py | 107 +++++++++++++ .../M02_MasterTemplete_Api_Fetch.ts | 63 ++++++-- .../M02_MasterTemplete_Layer.ts | 65 ++++++-- .../M02_MasterTemplete_Router_Items.py | 73 ++------- .../M02_MasterTemplete_Router_Layers.py | 106 +++++++++---- .../M02_MasterTemplete_TreePanel.css | 2 +- .../M02_MasterTemplete_TreePanel.ts | 34 +++-- .../M02_MasterTemplete_TreePanel_Dialogs.ts | 2 +- .../M02_MasterTemplete_TreePanel_Model.ts | 2 +- .../M02_MasterTemplete_TreePanel_Roots.ts | 141 +++++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 2 +- .../M02_MasterTemplete_UI_Page.ts | 63 +++++--- .../M02_MasterTemplete_UI_Side.ts | 30 ++-- .../M02_MasterTemplete_UI_Style.css | 2 +- resources/tester/helper_m02_treepanel.cjs | 2 +- resources/tester/test_m02_layer_access.py | 142 ++++++++++++++++++ resources/tester/test_m02_layer_screen.py | 84 ++++++++--- resources/tester/test_m02_template_layers.py | 15 +- resources/tester/test_m02_treepanel.py | 2 +- ui_template/ui_template_item_picker.ts | 3 +- ui_template/ui_template_locale_m2.ts | 8 +- 21 files changed, 754 insertions(+), 194 deletions(-) create mode 100644 M02_MasterTemplete/M02_Layer_Access.py create mode 100644 M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts create mode 100644 resources/tester/test_m02_layer_access.py diff --git a/M02_MasterTemplete/M02_Layer_Access.py b/M02_MasterTemplete/M02_Layer_Access.py new file mode 100644 index 00000000..fe1b7143 --- /dev/null +++ b/M02_MasterTemplete/M02_Layer_Access.py @@ -0,0 +1,107 @@ +"""M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀 +(PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`. + + system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN + company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) · + 남의 회사 = SYSTEM_ADMIN 읽기만 + personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 · + 다른 회사 = SYSTEM_ADMIN 읽기만 +owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) · +읽기로 남의 층에 뼈대를 안 만듦. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +from M02_MasterTemplete import M02_Template_Layers as layers + +NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음" +_SHAPE = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")} + + +def is_system_admin(session: dict[str, Any]) -> bool: + return session.get("role") == "SYSTEM_ADMIN" + + +def is_company_admin(session: dict[str, Any]) -> bool: + return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) + + +@dataclass(frozen=True) +class Grant: + """층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨).""" + + layer: str + owner: str | None + write: bool + mine: bool + + +def own_owner(session: dict[str, Any], layer: str, company: Any = None) -> str | None: + """자기 층 주인 글 — 회사 없으면 None(`company` = 프로젝트 회사로 바꿔 끼울 때).""" + company = session.get("company_id") if company is None else company + if company is None: + return None + return str(company) if layer == "company" else f"{company}/{session.get('user_id')}" + + +def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Grant: + """읽기 권한이 없으면 403 · 회사 없는 사람의 자기 층은 409 · 모양이 틀리면 400.""" + if layer == "system": + return Grant(layer, None, is_system_admin(session), True) + if layer not in _SHAPE: + raise HTTPException(status_code=404, detail="없는 층입니다.") + own = own_owner(session, layer) + who = own if owner in (None, "") else str(owner) + if who is None: + raise HTTPException(status_code=409, detail=NO_COMPANY) + if not _SHAPE[layer].match(who): + raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」") + if who == own: + return Grant(layer, who, layer == "personal" or is_company_admin(session), True) + same_company = who.split("/")[0] == str(session.get("company_id")) + colleague = layer == "personal" and same_company and is_company_admin(session) + if not (is_system_admin(session) or colleague): + raise HTTPException( + status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음" + ) + return Grant(layer, who, False, False) + + +def require( + session: dict[str, Any], layer: str, owner: str | None = None, *, write: bool = False +) -> Grant: + """`grant` + 쓰기 권한까지 — 까닭은 층마다.""" + got = grant(session, layer, owner) + if write and not got.write: + if layer == "system": + detail = "마스터 템플릿은 시스템 관리자만 고침." + elif got.mine: + detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." + else: + detail = "남의 층은 읽기만." + raise HTTPException(status_code=403, detail=detail) + return got + + +def folder(got: Grant) -> Path: + """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦).""" + if got.owner is None: + raise HTTPException(status_code=409, detail=NO_COMPANY) + try: + if got.layer == "company": + path = layers.company_dir(got.owner) + else: + company, user = got.owner.split("/", 1) + path = layers.personal_dir(company, user) + except ValueError as error: + raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error + if not got.mine and not path.is_dir(): + raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") + return path diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 3a3d6919..265fa33b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -53,21 +53,35 @@ function reason(detail: unknown, status: number): string { return lines.filter(Boolean).join(" · ") || `HTTP ${status}`; } -/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) */ +/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) · + * `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) — + * 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */ let activeLayer: Layer = "system"; -export const setActiveLayer = (layer: Layer): void => { +let activeOwner: string | null = null; +export const setActiveLayer = (layer: Layer, owner: string | null = null): void => { activeLayer = layer; + activeOwner = owner; }; export const getActiveLayer = (): Layer => activeLayer; -const scoped = (path: string, init: RequestInit = {}): Promise => - call(`${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`, init); +export const getActiveOwner = (): string | null => activeOwner; const enc = encodeURIComponent; +/** 지금 층 길이면 `owner` 인자를 붙임 */ +const owned = (layer: Layer, path: string): string => + layer === activeLayer && activeOwner && layer !== "system" && layer !== "project" + ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}` + : path; +const scoped = (path: string, init: RequestInit = {}): Promise => + call( + owned(activeLayer, `${activeLayer === "system" ? "" : `/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 item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => - `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`; + owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`); /** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */ export const listTemplates = async ( @@ -75,7 +89,7 @@ export const listTemplates = async ( projectId: string | null, ): Promise => { const got = await call( - layer === "system" ? head(layer) : `${head(layer)}${q(projectId)}`, + layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`), ); return Array.isArray(got) ? got : got.양식; }; @@ -108,7 +122,7 @@ export const deleteLayerTemplate = ( name: string, 판: string, ): Promise<{ ok: boolean }> => - call(`/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, { + call(owned(layer, `/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`), { method: "DELETE", }); @@ -384,10 +398,10 @@ export interface InputLinks { } const itemPath = (layer: Layer, key: string, tail: string): string => - `/layers/${layer}/items/${enc(key)}/${tail}`; + owned(layer, `/layers/${layer}/items/${enc(key)}/${tail}`); export const fetchItemRows = (layer: Layer): Promise => - call(`/items?layer=${layer}`); + call(owned(layer, `/items?layer=${layer}`)); export const fetchItemInputs = (layer: Layer, key: string): Promise => call(itemPath(layer, key, "inputs")); @@ -404,7 +418,7 @@ export const saveInputLinks = ( ): Promise<{ 판: string }> => call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) }); -/* --- 항목 나무(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */ +/* --- 구조물 라이브러리(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */ export interface TreeItem { key: string; 열: string; @@ -434,8 +448,31 @@ export interface TreeEdit { 단위?: string; } -export const fetchTree = (layer: Layer): Promise => call(`/layers/${layer}/tree`); +/** `owner` = 남의 층(없으면 자기 층) — 지금 층과 따로 받음(나무 부품이 여러 뿌리를 그림) */ +const treePath = (layer: Layer, owner: string | null): string => + `/layers/${layer}/tree${owner ? `?owner=${enc(owner)}` : ""}`; + +export const fetchTree = (layer: Layer, owner: string | null = null): Promise => + call(treePath(layer, owner)); /** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */ -export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise => - call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); +export const editTree = ( + layer: Layer, + edit: TreeEdit, + 판: string, + owner: string | null = null, +): Promise => + call(treePath(layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); + +/** 남의 층 목록 한 줄(PLAN 27-1c) — 회사 `{C}` · 개인 `{C}/{U}` */ +export interface LayerOwner { + owner: string; + name: string; +} + +/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */ +export const fetchOwners = ( + layer: "company" | "personal", + company: string | null = null, +): Promise => + call(`/layers/${layer}/owners${company ? `?company=${enc(company)}` : ""}`); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 7725b25b..bb640778 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Layer.ts - * M02 화면의 층 — 주소 인자(`#/m02-master-template?layer=personal|company` · 없으면 system) · - * 층별 제목 · 고칠 권한(시스템 = SYSTEM_ADMIN · 회사 = role ADMIN 또는 is_master · 개인 = 본인) · + * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 + * `#/m02-master-template?open=구조물조합|일위대가|옵션|도면&root=master|company|personal&owner=…` + * (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · * 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대. * ========================================================================== */ @@ -13,31 +14,63 @@ import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_Api_Fetch"; export type ManageLayer = "system" | "company" | "personal"; +/** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */ +export type Root = "master" | "company" | "personal"; +export type OpenTarget = "구조물조합" | "일위대가" | "옵션" | "도면"; -/** 주소 인자에서 층 — 모르는 값 · 없음 = 시스템 */ -export function layerFromHash(): ManageLayer { - const value = currentQuery().get("layer"); - return value === "company" || value === "personal" ? value : "system"; +export interface Entry { + root: Root; + /** 남의 층 주인(회사 `{C}` · 개인 `{C}/{U}`) — 자기 층 = null */ + owner: string | null; + open: OpenTarget; } -export const titleOf = (layer: ManageLayer): string => - layer === "company" - ? L("M02_TitleCompany") - : layer === "personal" - ? L("M02_TitlePersonal") - : L("M02_Title"); +const OPENS: OpenTarget[] = ["구조물조합", "일위대가", "옵션", "도면"]; + +export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root); + +/** 주소 인자에서 진입 — 모르는 값 · 없음 = 마스터 · 구조물 조합 */ +export function entryFromHash(): Entry { + const query = currentQuery(); + const raw = query.get("root") ?? query.get("layer"); + const root: Root = raw === "company" || raw === "personal" ? raw : "master"; + const open = OPENS.find((one) => one === query.get("open")) ?? "구조물조합"; + return { root, owner: root === "master" ? null : query.get("owner") || null, open }; +} + +/** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */ +export const entryQuery = (entry: Entry): Record => ({ + open: entry.open, + root: entry.root, + ...(entry.owner ? { owner: entry.owner } : {}), +}); + +/** 자기 층 주인 글 — 회사 없으면 null */ +export const ownOwner = (layer: ManageLayer, user: SessionUser | null): string | null => + !user?.company_id || layer === "system" + ? null + : layer === "company" + ? String(user.company_id) + : `${user.company_id}/${user.id}`; export interface LayerAccess { /** 이 층을 고칠 수 있나 — 서버도 같은 규칙으로 막음 */ canWrite: boolean; - /** 회사 · 개인 층인데 회사가 없음 — 「회사부터 등록」 안내 */ + /** 회사 · 개인 자기 층인데 회사가 없음 — 「회사부터 등록」 안내 */ needCompany: boolean; } -export function accessFor(layer: ManageLayer, user: SessionUser | null): LayerAccess { +/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */ +export function accessFor( + layer: ManageLayer, + owner: string | null, + user: SessionUser | null, +): LayerAccess { if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false }; - if (!user?.company_id) return { canWrite: false, needCompany: true }; - const companyAdmin = user.role === "ADMIN" || !!user.is_master; + const own = ownOwner(layer, user); + if (owner && owner !== own) return { canWrite: false, needCompany: false }; + if (!own) return { canWrite: false, needCompany: true }; + const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master; return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 4399ecce..f9b6fe53 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -1,9 +1,7 @@ """M02 층 집계표 API — `/api/m02/layers/{layer}/…` · 항목 목록 · 항목 복사(PLAN 21-1 · 21-2). -⚠ 등록은 `main.py`(로그인만) · 층 권한은 여기서(`Router_Layers._layer_dir` 와 같은 규칙): - system 읽기 = 로그인 누구나(항목 고르기) · 쓰기 = SYSTEM_ADMIN - company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) - personal 읽기 · 쓰기 = 본인(SYSTEM_ADMIN 은 누구나) · 회사 없음 = 쓰기 409 「회사부터 등록」 +⚠ 등록은 `main.py`(로그인만) · 층 권한은 `M02_Layer_Access`(양식 길 `Router_Layers` 와 한 벌 · +PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) · 회사 없음 = 409. 옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로. 회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`). 결정(PLAN 21-9): 일위대가(UA) 갈라 내기는 새 UA 가 M01 시스템 마스터에 생기므로 층 길에서도 @@ -19,13 +17,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Branch as branch from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree -from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_MasterTemplete_Router import ( BranchBody, MaterialBranchBody, @@ -37,7 +35,7 @@ router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) Layer = Literal["system", "company", "personal"] Session = dict[str, Any] -NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음" +NO_COMPANY = access.NO_COMPANY class InputLinksBody(BaseModel): @@ -63,55 +61,16 @@ class CopyItemBody(BaseModel): key: str -def _admin(session: Session) -> bool: - return session.get("role") == "SYSTEM_ADMIN" - - -def _company_admin(session: Session) -> bool: - return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) - - -def _owner(session: Session, layer: str, owner: str | None) -> str | None: - """층 주인 글 — 회사 `{회사}` · 개인 `{회사}/{사용자}` · 시스템 None · - 남의 것은 SYSTEM_ADMIN 만.""" - if layer == "system": - return None - company, user = session.get("company_id"), session.get("user_id") - mine = str(company) if layer == "company" else f"{company}/{user}" - if owner in (None, "", mine): - return None if company is None else mine - if not _admin(session): - raise HTTPException(status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표입니다.") - return owner - - -def _folder(layer: str, owner: str | None) -> Path: - if layer == "system": - return store.FOLDER - try: - if layer == "company": - return layers.company_dir(owner) - company, user = str(owner).split("/", 1) - return layers.personal_dir(company, user) - except ValueError as error: - raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error - - def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: - """층 폴더(권한까지) — 회사 · 개인은 없으면 빈 뼈대를 만듦. 회사 없는 사람: 쓰기 409.""" + """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦.""" + got = access.require(session, layer, owner, write=write) if layer == "system": - if write and not _admin(session): - raise HTTPException(status_code=403, detail="마스터 템플릿은 시스템 관리자만 고침.") return store.FOLDER - who = _owner(session, layer, owner) - if who is None: - raise HTTPException(status_code=409, detail=NO_COMPANY) - if write and layer == "company" and not _company_admin(session): - raise HTTPException( - status_code=403, detail="회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." - ) - folder = _folder(layer, who) - items_store.ensure(folder) + folder = access.folder(got) + if got.mine: + items_store.ensure(folder) + elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): + raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return folder @@ -127,9 +86,9 @@ def _call(folder: Path, fn, *args): def _listing(session: Session, layer: str, owner: str | None) -> dict[str, Any]: - who = None if layer == "system" else _owner(session, layer, owner) - if layer != "system" and who is None: + if layer != "system" and not owner and access.own_owner(session, layer) is None: return {"layer": layer, "owner": None, "items": []} # 회사 없음 = 빈 그룹 + who = access.grant(session, layer, owner).owner folder = _root(session, layer, who) return {"layer": layer, "owner": who, "items": items_store.items(folder)} @@ -154,9 +113,7 @@ def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> """회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 SS 키 · `본뜬`).""" target = _root(session, "personal", None, write=True) source = _root(session, body.from_layer, body.from_owner) - owner = ( - None if body.from_layer == "system" else _owner(session, body.from_layer, body.from_owner) - ) + owner = access.grant(session, body.from_layer, body.from_owner).owner origin = {"layer": body.from_layer, "owner": owner, "key": body.key} try: return items_store.copy_item(source, target, body.key, origin) @@ -285,7 +242,7 @@ def post_combo( ): """일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만.""" folder = _root(session, layer, owner, write=True) - if not _admin(session): + if not access.is_system_admin(session): raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.") return _call( folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 81a867aa..a9d65b1a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -1,10 +1,9 @@ """M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」. -⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서: - system 읽기 · 쓰기는 시스템 관리자(옛 `/api/m02/templates` 길과 같은 저장소) - company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자) - personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기) - project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀 +⚠ 등록은 `main.py`(로그인만) · 시스템 · 회사 · 개인 층 권한은 `M02_Layer_Access`(나무 · 항목 길과 +한 벌 · PLAN 27-1c · `owner` = 회사 `{C}` · 개인 `{C}/{U}`) · 남의 층 목록 = `/layers/{층}/owners`: + project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀 · + 가져오기(apply · sources)는 같은 회사 사람 개인 층도 읽음 """ from __future__ import annotations @@ -26,6 +25,7 @@ from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection +from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill @@ -115,6 +115,14 @@ async def _company_projects(company_id: int) -> list[dict[str, Any]]: return [{"project_id": str(row[0]), "name": row[1], "storage_path": row[2]} for row in rows] +async def _companies() -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT id, name FROM companies WHERE deleted_at IS NULL ORDER BY id") + rows = await cursor.fetchall() + return [{"company_id": row[0], "name": row[1]} for row in rows] + + async def _cross_designs(project_id: str) -> list[dict[str, Any]]: """최신 노선의 B06 횡단 설계 — 못 읽으면 빈 목록(관 연장이 빈칸으로 섬 · 0 아님).""" try: @@ -129,12 +137,8 @@ async def _cross_designs(project_id: str) -> list[dict[str, Any]]: # ── 권한 · 자리 ─────────────────────────────────────── -def _is_system_admin(session: dict[str, Any]) -> bool: - return session.get("role") == "SYSTEM_ADMIN" - - -def _is_company_admin(session: dict[str, Any]) -> bool: - return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) +_is_system_admin = access.is_system_admin +_is_company_admin = access.is_company_admin async def _project(session: dict[str, Any], project_id: str | None) -> dict[str, Any]: @@ -172,28 +176,23 @@ async def _layer_dir( *, project_id: str | None, user_id: int | None = None, + owner: str | None = None, write: bool = False, ) -> Path: - """층 폴더 — 읽기·쓰기 권한까지 여기서 가름.""" - if layer == "system": - if write and not _is_system_admin(session): - raise HTTPException(status_code=403, detail="시스템 양식은 시스템 관리자만.") - return layers.system_dir() + """층 폴더 — 권한은 `M02_Layer_Access` 한 벌(나무 · 항목 길과 같음 · PLAN 27-1c) · + `owner` 없으면 프로젝트(없으면 세션) 회사의 회사 층 · 개인 층(`user_id` 또는 본인).""" if layer == "project": return layers.project_dir((await _project(session, project_id))["root"]) - project = await _project(session, project_id) if project_id else None - company_id = _company_of(session, project) - if layer == "company": - if write and not _is_company_admin(session): - raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.") - return _ensured(layers.company_dir(company_id)) - if layer == "personal": - owner = int(user_id) if user_id is not None else int(session["user_id"]) - if write and owner != session.get("user_id"): - raise HTTPException(status_code=403, detail="개인 양식은 본인만 고칩니다.") - await _same_company_user(session, owner, company_id) - return _ensured(layers.personal_dir(company_id, owner)) - raise HTTPException(status_code=404, detail="없는 층입니다.") + if layer == "system": + access.require(session, layer, write=write) + return layers.system_dir() + if not owner: + project = await _project(session, project_id) if project_id else None + company_id = _company_of(session, project) + who = session["user_id"] if user_id is None else user_id + owner = str(company_id) if layer == "company" else f"{company_id}/{who}" + got = access.require(session, layer, owner, write=write) + return _ensured(access.folder(got)) if got.mine else access.folder(got) def _ensured(folder: Path) -> Path: @@ -209,14 +208,49 @@ def _bad(error: ValueError) -> HTTPException: # ── 층 길 ───────────────────────────────────────────── +@router.get("/layers/{layer}/owners") +async def list_owners( + layer: Literal["company", "personal"], + company: int | None = Query(None), + session: dict[str, Any] = Depends(verify_session), +) -> list[dict[str, Any]]: + """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌). + 회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 · + 회사 관리자는 자기 회사만).""" + if layer == "company": + if not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.") + rows = [(str(r["company_id"]), r["name"]) for r in await _companies()] + else: + target = session.get("company_id") if company is None else company + if target is None: + raise HTTPException(status_code=409, detail=access.NO_COMPANY) + mine = str(target) == str(session.get("company_id")) + if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))): + raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.") + me = session.get("user_id") + users = await _company_users(int(target)) + rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me] + found = [] + for owner, name in rows: + got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer)) + try: + access.folder(got) + except HTTPException: + continue + found.append({"owner": owner, "name": name}) + return found + + @router.get("/layers/{layer}/templates") async def list_layer( layer: Layer, project_id: str | None = Query(None), user_id: int | None = Query(None), + owner: str | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: - folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id) + folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id, owner=owner) rows = await asyncio.to_thread(layers.list_templates, folder) manifest = await asyncio.to_thread(layers.read_manifest, folder) for row in rows: @@ -231,9 +265,10 @@ async def read_layer_template( name: str, project_id: str | None = Query(None), user_id: int | None = Query(None), + owner: str | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: - folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id) + folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id, owner=owner) try: found = await asyncio.to_thread(layers.read_template, folder, kind, name) except ValueError as error: @@ -258,9 +293,13 @@ async def save_layer_template( name: str, body: SaveBody, project_id: str | None = Query(None), + user_id: int | None = Query(None), + owner: str | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: - folder = await _layer_dir(session, layer, project_id=project_id, write=True) + folder = await _layer_dir( + session, layer, project_id=project_id, user_id=user_id, owner=owner, write=True + ) document = body.문서 try: layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀 @@ -318,6 +357,7 @@ async def delete_layer_template( name: str, 판: str | None = Query(None), project_id: str | None = Query(None), + owner: str | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: """개인 = 본인만 · 회사 = 회사 관리자만 · 시스템 = 시스템 관리자 · 프로젝트 층은 거절 · @@ -326,7 +366,7 @@ async def delete_layer_template( raise HTTPException( status_code=403, detail="개인 · 회사 · 시스템 층 양식만 지울 수 있습니다." ) - folder = await _layer_dir(session, layer, project_id=project_id, write=True) + folder = await _layer_dir(session, layer, project_id=project_id, owner=owner, write=True) if (kind, name) in store.IN_USE: raise HTTPException(status_code=409, detail=f"쓰는 양식이라 못 지움 「{name}」") try: diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css index 7efac906..47603a99 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -1,4 +1,4 @@ -/* 관리 화면 항목 나무 — 줄 모양은 공용 ui-tree · ui-sidebar-row */ +/* 관리 화면 구조물 라이브러리 — 줄 모양은 공용 ui-tree · ui-sidebar-row */ .m02-tree { padding-top: var(--spacing-8); display: flex; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index b6a8ccdb..f6f02951 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel.ts - * 관리 화면(마스터 · 회사 · 개인 공용) 좌측 「항목 나무」 — 폴더 → … → 항목(깊이 자유) · 찾기. + * 관리 화면(마스터 · 회사 · 개인 공용) 「구조물 라이브러리」 한 층의 나무 — 폴더 → … → 항목(깊이 자유) · 찾기. + * 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 뿌리 셋 배치는 `_Roots` · 자료 길은 `api` 로 바꿔 끼움. * 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목. * 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 — * `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`. @@ -44,6 +45,15 @@ import { export interface TreePanelOptions { layer: Layer; + /** 남의 층 주인(회사 `{C}` · 개인 `{C}/{U}`) — 없으면 자기 층 */ + owner?: string | null; + /** 맨 위 줄 글 — 없으면 층 이름 */ + label?: string; + /** 나무 읽기 · 고치기 길 — 없으면 구조물 조합 나무(`/layers/{층}/tree`) */ + api?: { + fetch: (layer: Layer, owner: string | null) => Promise; + edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise; + }; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ @@ -67,13 +77,17 @@ const why = (error: unknown): string => (error instanceof Error ? error.message const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text }); export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { - const layerLabel = L( - opt.layer === "company" - ? "M02_PickerCompany" - : opt.layer === "personal" - ? "M02_PickerPersonal" - : "M02_PickerSystem", - ); + const layerLabel = + opt.label ?? + L( + opt.layer === "company" + ? "M02_PickerCompany" + : opt.layer === "personal" + ? "M02_PickerPersonal" + : "M02_PickerSystem", + ); + const owner = opt.owner ?? null; + const api = opt.api ?? { fetch: fetchTree, edit: editTree }; let view: TreeView | null = null; let text = ""; let activeCol: string | null = null; @@ -175,7 +189,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { async function refresh(): Promise { const mine = ++seq; try { - const got = await fetchTree(opt.layer); + const got = await api.fetch(opt.layer, owner); if (mine !== seq) return; view = got; } catch (error) { @@ -217,7 +231,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { if (!view || busy) return; busy = true; try { - const got = await editTree(opt.layer, body_, view.판); + const got = await api.edit(opt.layer, body_, view.판, owner); view = got; const note: TreeNote | undefined = got.알림; // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index 2735c7cf..2eb6969f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel_Dialogs.ts - * 항목 나무 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음. + * 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음. * 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel"). * ========================================================================== */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts index 4643cf08..3164a829 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel_Model.ts - * 관리 화면 항목 나무의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 · + * 관리 화면 구조물 라이브러리의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 · * 폴더 목록. 항목 경로는 폴더 이름 배열 + 마지막 마디가 항목 이름(PLAN 26-1 · 깊이 자유). * ========================================================================== */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts new file mode 100644 index 00000000..4d78b1eb --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -0,0 +1,141 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_Roots.ts + * 라이브러리 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1b · c) — 나무 종류와 무관한 공용 부품 + * (구조물 라이브러리 · 일위대가 라이브러리가 함께 씀). 고른 뿌리(`active`) 아래에 부르는 쪽 나무(`panel`)를 붙임. + * 시스템 관리자 「회사」 → 회사 목록 → (회사 공용 · 그 회사 사람) + * 회사 관리자 「개인」 → (내 것 · 같은 회사 사람) + * 그 밖 마스터 · 회사 · 개인 한 줄씩 + * 목록은 펼칠 때 불러옴(`fetchOwners`) · 다른 뿌리를 누르면 `go`(부르는 쪽이 해시로 다시 엶) · + * 읽기만 뿌리는 줄 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createTreeRow } from "@ui/ui_template_tree"; +import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchOwners, type LayerOwner } from "./M02_MasterTemplete_Api_Fetch"; +import { + accessFor, + buildNeedCompany, + layerOfRoot, + ownOwner, + type Root, +} from "./M02_MasterTemplete_Layer"; + +export interface RootsOptions { + user: SessionUser | null; + /** 지금 고른 뿌리 — `owner` null = 자기 층 */ + active: { root: Root; owner: string | null }; + /** 고른 뿌리 아래에 붙일 나무 */ + panel: HTMLElement; + /** 다른 뿌리를 누름 — `owner` null = 자기 층 */ + go: (root: Root, owner: string | null) => void; +} + +const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text }); +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + +export function createLibraryRoots(opt: RootsOptions): HTMLElement { + const { user, active } = opt; + const isSystem = user?.role === "SYSTEM_ADMIN"; + const companyAdmin = isSystem || user?.role === "ADMIN" || !!user?.is_master; + const ownCompany = ownOwner("company", user); + const activeCompany = active.owner?.split("/")[0] ?? ownCompany; + + /** 층 하나 — 누르면 그 뿌리로 · 고른 뿌리면 아래에 나무(누르면 접기/펴기) */ + function leaf(label: string, root: Root, owner: string | null, depth: number): HTMLElement[] { + const current = root === active.root && owner === active.owner; + const readOnly = !accessFor(layerOfRoot(root), owner, user).canWrite; + const text = readOnly ? `${label} · ${L("M02_RootReadOnly")}` : label; + const wrap = el("div", { className: "m02-roots__panel", children: current ? [opt.panel] : [] }); + wrap.style.paddingLeft = `calc(${depth + 1} * var(--spacing-16))`; + const row = createTreeRow( + { id: `${root}|${owner}`, label: text, count: null }, + "", + depth, + () => { + if (!current) return opt.go(root, owner); + wrap.hidden = !wrap.hidden; + }, + ); + row.classList.toggle("is-active", current); + return current ? [row, wrap] : [row]; + } + + /** 묶음 — 펼칠 때 한 번 불러옴(`load`) · `open` = 고른 뿌리가 안에 있어 처음부터 펼침 */ + function group( + label: string, + depth: number, + open: boolean, + load: () => Promise, + ): HTMLElement { + const kids = el("div", { className: "ui-tree__kids" }); + kids.hidden = !open; + let loaded = false; + const fill = async (): Promise => { + loaded = true; + kids.replaceChildren(hint(L("M02_PickerLoading"))); + try { + const nodes = await load(); + kids.replaceChildren(...(nodes.length ? nodes : [hint(L("M02_RootNone"))])); + } catch (error) { + loaded = false; + kids.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error)))); + } + }; + const row = createTreeRow({ id: label, label, count: null }, open ? "▾" : "▸", depth, () => { + kids.hidden = !kids.hidden; + const caret = row.querySelector(".ui-tree__caret"); + if (caret) caret.textContent = kids.hidden ? "▸" : "▾"; + if (!kids.hidden && !loaded) void fill(); + }); + if (open) void fill(); + return el("div", { className: "ui-tree__branch", children: [row, kids] }); + } + + /** 목록에 없는 고른 층(해시로 바로 들어옴 · 탈퇴한 사람 등)도 줄을 세움 — 나무를 붙일 자리 */ + const withActive = (list: LayerOwner[], owner: string | null, fits: boolean): LayerOwner[] => + owner && fits && !list.some((one) => one.owner === owner) + ? [...list, { owner, name: owner }] + : list; + + /** 그 회사 사람들의 개인 층(본인 뺌) */ + const people = async (company: string | null, depth: number): Promise => { + const fits = active.root === "personal" && activeCompany === (company ?? ownCompany); + return withActive(await fetchOwners("personal", company), active.owner, fits).flatMap((one) => + leaf(one.name, "personal", one.owner, depth), + ); + }; + + const master = leaf(L("M02_PickerSystem"), "master", null, 0); + if (!ownCompany && !isSystem) { + return el("div", { className: "m02-roots", children: [...master, buildNeedCompany()] }); + } + + // 시스템 관리자 — 회사 목록 → 회사 공용 · 그 회사 사람 + const companies = (): HTMLElement => + group(L("M02_PickerCompany"), 0, active.root === "company" || !!active.owner, async () => + withActive(await fetchOwners("company"), activeCompany, !!active.owner).map((one) => { + const mine = one.owner === ownCompany; + const here = !!active.owner || active.root === "company"; + return group(one.name, 1, here && activeCompany === one.owner, async () => [ + ...leaf(L("M02_RootShared"), "company", mine ? null : one.owner, 2), + ...(await people(one.owner, 2)), + ]); + }), + ); + + const company = isSystem ? [companies()] : leaf(L("M02_PickerCompany"), "company", null, 0); + // 회사 관리자 — 개인 → 내 것 · 같은 회사 사람 · 시스템 관리자는 남의 개인을 회사 아래에서 봄 + const personal = !ownCompany + ? [] + : companyAdmin && !isSystem + ? [ + group(L("M02_PickerPersonal"), 0, active.root === "personal", async () => [ + ...leaf(L("M02_RootMine"), "personal", null, 1), + ...(await people(null, 1)), + ]), + ] + : leaf(L("M02_PickerPersonal"), "personal", null, 0); + return el("div", { className: "m02-roots", children: [...master, ...company, ...personal] }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 78b7e7da..1f192869 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -106,7 +106,7 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean { } /** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 · - * `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(항목 나무 · PLAN 26-7c) */ + * `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(구조물 라이브러리 · PLAN 26-7c) */ export interface MainExtras { top?: HTMLElement; aside?: HTMLElement; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 07afe8f7..a86599a4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -1,54 +1,74 @@ /* ============================================================================= * M02_MasterTemplete_UI_Page.ts * 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) · - * 「구조물 조합」을 열면 편집 칸 왼쪽에 항목 나무(주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c) + * 「구조물 조합」을 열면 편집 칸 왼쪽에 구조물 라이브러리(뿌리 셋 · 주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c) * - * 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌. - * 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`). + * 한 화면에 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 뿌리 = 진입 해시 `?open=&root=&owner=` + * (`M02_MasterTemplete_Layer.ts`) · 다른 뿌리를 누르면 해시를 바꿔 이 화면을 다시 세움. + * 화면은 로그인만 확인 — 고치기는 뿌리별 권한일 때만 켬(서버도 막음 · `accessFor`). * ========================================================================== */ import "@ui/ui_template_workflow_layout.css"; +import { ROUTES } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; +import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, - buildNeedCompany, buildReadOnlyBar, - layerFromHash, - titleOf, + entryFromHash, + entryQuery, + layerOfRoot, + ownOwner, + type Root, } from "./M02_MasterTemplete_Layer"; import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; +import { createLibraryRoots } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide } from "./M02_MasterTemplete_UI_Side"; import "./M02_MasterTemplete_UI_Style.css"; -export async function renderM02MasterTemplate(root: HTMLElement): Promise { +export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); - const layer = layerFromHash(); - setActiveLayer(layer); - const access = accessFor(layer, user); - if (access.needCompany) { - root.replaceChildren(buildNeedCompany()); - return; + const entry = entryFromHash(); + let layer = layerOfRoot(entry.root); + // 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 뿌리 = 마스터(뿌리 자리에 안내) + let owner = entry.owner && entry.owner !== ownOwner(layer, user) ? entry.owner : null; + if (accessFor(layer, owner, user).needCompany) { + [entry.root, layer, owner] = ["master", "system", null]; } - const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻) - // 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사] - const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null; - // 항목 나무 — 고르기 모달과 같은 폴더 나무(세 층 공용) · 「구조물 조합」 편집 칸 왼쪽 + setActiveLayer(layer, owner); + const isAdmin = accessFor(layer, owner, user).canWrite; // 이 뿌리를 고칠 수 있나(하위 부품의 「관리자」) + // 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사] + const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null; + // 고른 뿌리의 나무 — 고르기 모달과 같은 폴더 나무 · 「구조물 조합」 편집 칸 왼쪽 const tree = createTreePanel({ layer, + owner, + label: L("M02_KindTable"), canEdit: canEdit(layer, isAdmin), onPick: (item) => void main.openTable(layer, item.열), settle: () => main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); + const go = async (next: Root, nextOwner: string | null): Promise => { + // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 + if (!(await main.confirmLeave())) return; + navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery({ ...entry, root: next, owner: nextOwner })); + }; + const roots = createLibraryRoots({ + user, + active: { root: entry.root, owner }, + panel: tree.root, + go: (next, nextOwner) => void go(next, nextOwner), + }); const aside = el("aside", { className: "m02-shell__tree", - children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), tree.root], + children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), roots], }); const main = createMain( isAdmin, @@ -81,7 +101,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise const layout = el("div", { className: "ui-workflow-layout m02-master" }); const overlays = createWorkflowOverlays({ - title: titleOf(layer), + title: L("M02_Title"), optionsContent: el("div", { className: "m02-master__side", children: [side.root] }), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), @@ -96,5 +116,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise root.innerHTML = ""; root.append(layout); void main.open(home); + // 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만(일위대가는 27-2 컨테이너가 생기면) · + // 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림 + if (entry.open === "옵션" || entry.open === "도면") side.reveal(entry.open); void tree.refresh(); + // 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게) + return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 0fae8baf..2be9f784 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -6,7 +6,7 @@ * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`. * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. - * 항목 나무는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임). + * 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임). * ========================================================================== */ import { @@ -80,6 +80,8 @@ export interface SideHandle { refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; + /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */ + reveal: (target: "옵션" | "도면") => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -152,18 +154,17 @@ export function buildSide(opt: SideOptions): SideHandle { ); summary.classList.remove("is-collapsed"); summary.classList.add("m02-side__summary"); + // 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 + const extras = + layer === "system" + ? { + 옵션: section(options.title, options.body), + 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), + } + : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [ - summary, - // 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 - ...(layer === "system" - ? [ - section(options.title, options.body), - section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), - ] - : []), - ], + children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], }); attachCollapsible(listHost); @@ -362,6 +363,11 @@ export function buildSide(opt: SideOptions): SideHandle { } } + const reveal = (target: "옵션" | "도면"): void => { + extras?.[target].classList.remove("is-collapsed"); + if (target === "옵션") options.body.querySelector("button")?.click(); + }; + void refresh(); - return { root, refresh, paint }; + return { root, refresh, paint, reveal }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index a26b107c..3551963a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -216,7 +216,7 @@ font-size: 11px; } -/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 항목 나무(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */ +/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 구조물 라이브러리(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */ .m02-shell { display: flex; align-items: stretch; diff --git a/resources/tester/helper_m02_treepanel.cjs b/resources/tester/helper_m02_treepanel.cjs index 2b7a3be9..887c03dc 100644 --- a/resources/tester/helper_m02_treepanel.cjs +++ b/resources/tester/helper_m02_treepanel.cjs @@ -1,4 +1,4 @@ -/* M02 항목 나무 자료(`M02_MasterTemplete_TreePanel_Model.ts`) 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 JSON 으로 냄. */ +/* M02 구조물 라이브러리 자료(`M02_MasterTemplete_TreePanel_Model.ts`) 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 JSON 으로 냄. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); diff --git a/resources/tester/test_m02_layer_access.py b/resources/tester/test_m02_layer_access.py new file mode 100644 index 00000000..42e5189b --- /dev/null +++ b/resources/tester/test_m02_layer_access.py @@ -0,0 +1,142 @@ +"""M02 층 권한 한 벌(PLAN 27-1c · `M02_Layer_Access`) — 나무 · 항목 · 양식 · 남의 층 목록 같은 규칙. + +① `grant` 표 — 역할(일반 · 회사 관리자 · 시스템 관리자) × 층(마스터 · 자기 회사 · 남의 회사 · + 자기 개인 · 같은 회사 남 · 다른 회사 남) = 읽기 · 수정 · 막힘. +② 임시 storage — 남의 층은 자리가 있을 때만(없으면 404 · 폴더 안 만듦) · 나무 · 양식 길 같은 답 · + 남의 층 목록(회사 = 시스템 관리자 · 개인 = 회사 관리자 자기 회사 · 본인 뺌 · 자리 있는 것만). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from fastapi import HTTPException + +from config import config_system +from M02_MasterTemplete import M02_Layer_Access as access +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 +from resources.tester.test_m02_tree import _app + +USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} +ADMIN = {**USER, "role": "ADMIN"} +MASTER = {**USER, "is_master": True} +SYSADMIN = {**USER, "role": "SYSTEM_ADMIN"} + + +def _answer(session: dict[str, Any], layer: str, owner: str | None) -> str: + try: + return "수정" if access.grant(session, layer, owner).write else "읽기" + except HTTPException as error: + return str(error.status_code) + + +def test_권한_표(): + cases = [ + ("system", None), + ("company", None), + ("company", "9"), + ("personal", None), + ("personal", "7/43"), + ("personal", "9/90"), + ] + got = { + name: [_answer(s, *c) for c in cases] + for name, s in ( + ("일반", USER), + ("회사관리", ADMIN), + ("마스터", MASTER), + ("시스템", SYSADMIN), + ) + } + assert got == { + "일반": ["읽기", "읽기", "403", "수정", "403", "403"], + "회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"], + "마스터": ["읽기", "수정", "403", "수정", "읽기", "403"], + "시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"], + } + # 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409 + assert access.grant(USER, "personal", "7/42").write is True + assert _answer(SYSADMIN, "personal", "9") == "400" + assert _answer({**USER, "company_id": None}, "company", None) == "409" + with pytest.raises(HTTPException) as caught: + access.require(ADMIN, "personal", "7/43", write=True) + assert caught.value.status_code == 403 + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.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) + store.renumber() + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + users = {42: 7, 43: 7, 44: 7, 90: 9} + + async def company_users(company_id: int) -> list[dict[str, Any]]: + return [{"user_id": u, "name": f"u{u}"} for u, c in users.items() if c == company_id] + + async def companies() -> list[dict[str, Any]]: + return [{"company_id": c, "name": f"c{c}"} for c in (7, 8, 9)] + + monkeypatch.setattr(layers_router, "_company_users", company_users) + monkeypatch.setattr(layers_router, "_companies", companies) + session: dict[str, Any] = dict(USER) + return {"client": _app(session), "session": session, "storage": storage} + + +def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): + client, session, storage = world["client"], world["session"], world["storage"] + session.update(ADMIN) + tree = "/api/m02/layers/personal/tree?owner=7/43" + table = "/api/m02/layers/personal/templates/table/구조물집계표?owner=7/43" + assert client.get(tree).status_code == 404 # 43 은 아직 아무것도 없음 + assert not (storage / "7/43").exists() # 읽기로 남의 폴더를 만들지 않음 + (storage / "7/43/templates").mkdir(parents=True) # 폴더만 있고 집계표 없음 + assert client.get(tree).status_code == 404 + assert client.get(table).status_code == 404 + assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 404 + assert not any((storage / "7/43/templates").iterdir()) # 읽기로 남의 층에 뼈대를 안 씀 + session.update(user_id=43) + assert client.get("/api/m02/layers/personal/tree").status_code == 200 # 43 첫 진입 = 뼈대 + session.update(user_id=42) + assert client.get(tree).status_code == 200 + assert client.get(table).status_code == 200 # 양식 길도 같은 규칙 + body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]} + assert client.post(tree, json=body).status_code == 403 # 읽기만 + assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200 + session.update(USER) + assert client.get(tree).status_code == 403 + assert client.get(table).status_code == 403 + session.update(SYSADMIN) + assert client.get(tree).status_code == 200 + assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만 + + +def test_남의_층_목록(world: dict[str, Any]): + client, session, storage = world["client"], world["session"], world["storage"] + for owner in ("7/43", "9/90"): # 층 폴더가 있는 사람만 목록에 + (storage / owner / "templates").mkdir(parents=True) + (storage / "9/templates").mkdir() + people = "/api/m02/layers/personal/owners" + assert client.get(people).status_code == 403 # 일반 사용자 + assert client.get("/api/m02/layers/company/owners").status_code == 403 + session.update(ADMIN) + assert client.get(people).json() == [ + {"owner": "7/43", "name": "u43"} + ] # 본인 · 44(자리 없음) 뺌 + assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람 + session.update(SYSADMIN) + assert client.get("/api/m02/layers/company/owners").json() == [ + {"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도 + {"owner": "9", "name": "c9"}, + ] + assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}] diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 0da4aa4d..c6ea78a2 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -1,8 +1,8 @@ -"""M02 층 화면(`?layer=personal|company`) 붙는 자리 시험 — 화면 조작은 ORCA. +"""M02 한 화면 뿌리 셋(PLAN 27-1 · 진입 해시 `?open=&root=&owner=`) 붙는 자리 시험 — 조작은 ORCA. · 권한 규칙(`accessFor`)을 Node 로 바로 돌림 — 시스템 SYSTEM_ADMIN · - 회사 ADMIN/is_master · 개인 본인 · 회사 없음 -· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어 + 회사 ADMIN/is_master/SYSTEM_ADMIN · 개인 본인 · 회사 없음 · 남의 층 읽기만 +· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어 · 뿌리 셋 """ from __future__ import annotations @@ -26,25 +26,35 @@ def _text(path: Path) -> str: @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_층별_고칠_권한(): - found = re.search(r"^export function accessFor[\s\S]*?^}", _text(LAYER), re.M) - assert found, "accessFor 를 못 찾음" + """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만.""" + text = _text(LAYER) + own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M) + found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M) + assert own and found, "ownOwner · accessFor 를 못 찾음" script = ( 'type ManageLayer = "system" | "company" | "personal";\n' - "interface SessionUser { role?: string; company_id?: number | null; is_master?: boolean }\n" + "interface SessionUser { id: string; role?: string; company_id?: number | null;" + " is_master?: boolean }\n" "interface LayerAccess { canWrite: boolean; needCompany: boolean }\n" + + own.group(0).replace("export ", "") + found.group(0).replace("export ", "") + "\nconst cases = JSON.parse(process.argv[1]);" - + "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1]))));" + + "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));" ) - sysadmin = {"role": "SYSTEM_ADMIN", "company_id": None} - admin = {"role": "ADMIN", "company_id": 3} - master = {"role": "USER", "company_id": 3, "is_master": True} - user = {"role": "USER", "company_id": 3} - orphan = {"role": "USER", "company_id": None} + sysadmin = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": None} + sys3 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 3} + admin = {"id": "5", "role": "ADMIN", "company_id": 3} + master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True} + user = {"id": "7", "role": "USER", "company_id": 3} + orphan = {"id": "8", "role": "USER", "company_id": None} cases = [ - ["system", sysadmin], ["system", admin], ["system", None], - ["company", admin], ["company", master], ["company", user], ["company", orphan], - ["personal", user], ["personal", admin], ["personal", orphan], ["personal", sysadmin], + ["system", None, sysadmin], ["system", None, admin], ["system", None, None], + ["company", None, admin], ["company", None, master], ["company", None, user], + ["company", None, orphan], ["company", None, sys3], + ["personal", None, user], ["personal", None, admin], ["personal", None, orphan], + ["personal", None, sysadmin], + ["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin], + ["personal", "9/90", sysadmin], ] # fmt: skip result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -57,8 +67,9 @@ def test_층별_고칠_권한(): no_company = {"canWrite": False, "needCompany": True} assert json.loads(result.stdout) == [ ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만 - ok, ok, ro, no_company, # 회사 = ADMIN · is_master · 일반은 읽기만 · 회사 없음 안내 + ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만 ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내 + ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만 ] # fmt: skip @@ -68,7 +79,35 @@ def test_주소_인자를_라우트에서_뗌(): assert "export function currentQuery()" in router assert "query?: Record" in router layer = _text(LAYER) - assert 'value === "company" || value === "personal" ? value : "system"' in layer + # 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로 + assert 'query.get("root") ?? query.get("layer")' in layer + assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer + assert '["구조물조합", "일위대가", "옵션", "도면"]' in layer + picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts") + assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker + + +def test_한_화면_뿌리_셋(): + """27-1b · c — 뿌리 셋 한 화면 · 다른 뿌리 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) · + 떠나면 층 길을 시스템으로 되돌림 · 목록은 펼칠 때 · 나무 부품은 owner · 자료 길을 받음.""" + page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") + assert "createLibraryRoots(" in page and "setActiveLayer(layer, owner)" in page + assert "await main.confirmLeave()" in page.split("const go =")[1].split("navigateTo(")[0] + assert 'dispose: () => setActiveLayer("system")' in page + assert "side.reveal(entry.open)" in page + roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") + assert 'fetchOwners("company")' in roots and 'fetchOwners("personal", company)' in roots + assert "if (!kids.hidden && !loaded) void fill();" in roots # 펼칠 때 불러옴 + panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") + assert ( + "api.fetch(opt.layer, owner)" in panel + and "api.edit(opt.layer, body_, view.판, owner)" in panel + ) + assert ( + "...(opt.canEdit ? { menu: () => menuOf(target) } : {})" in panel + ) # 읽기 뿌리 = 메뉴 없음 + api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") + assert "owner=${enc(activeOwner)}" in api and "/owners" in api def test_대시보드_카드_역할별_단추(): @@ -103,10 +142,13 @@ def test_층_길_접두어와_고정_풀림(): def test_읽기만_회사용_개인용으로_복사_막대(): page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert 'layer === "company" && !isAdmin ? buildReadOnlyBar(user)' in page - assert "readOnlyBar?.setColumn(column)" in page # 열 고름 → 막대 · 항목 나무가 함께 받음 - assert "access.needCompany" in page and "buildNeedCompany()" in page - assert "titleOf(layer)" in page + assert 'layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user)' in page + assert ( + "readOnlyBar?.setColumn(column)" in page + ) # 열 고름 → 막대 · 구조물 라이브러리가 함께 받음 + assert "accessFor(layer, owner, user).needCompany" in page # 회사 없음 = 마스터로 + assert "buildNeedCompany()" in _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") + assert 'title: L("M02_Title")' in page # 한 화면 한 제목 layer = _text(LAYER) assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 72d1cc1e..92c9f1bc 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -143,13 +143,24 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor assert client.get(mine).status_code == 200 session.update(user_id=43) theirs = mine + "&user_id=42" + # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만 + assert client.get(theirs).status_code == 403 + session["role"] = "ADMIN" assert client.get(theirs).status_code == 200 - # 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀 + assert client.get(mine + "&owner=7/42").status_code == 200 put = client.put( f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42", json={"판": None, "문서": {"열": []}}, ) - assert put.status_code == 200 # user_id 는 쓰기에서 무시 — 본인(43) 자리에 새로 씀 + assert put.status_code == 403 + assert not (world["storage"] / "7/42/templates/table/내표.json").exists() + session["role"] = "USER" + # 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀 + put = client.put( + f"/api/m02/layers/personal/templates/table/내표?project_id={P1}", + json={"판": None, "문서": {"열": []}}, + ) + assert put.status_code == 200 assert (world["storage"] / "7/43/templates/table/내표.json").is_file() got = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} diff --git a/resources/tester/test_m02_treepanel.py b/resources/tester/test_m02_treepanel.py index 798f1391..2e1835d1 100644 --- a/resources/tester/test_m02_treepanel.py +++ b/resources/tester/test_m02_treepanel.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""M02 관리 화면 항목 나무(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록. +"""M02 관리 화면 구조물 라이브러리(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록. 항목 경로 = 폴더 이름 배열 + 마지막 마디가 항목 이름 · 찾는 글이 있으면 맞는 항목과 위 폴더만. Node 헬퍼(`helper_m02_treepanel.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index f01f7d9f..98a4e504 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -52,7 +52,8 @@ export interface ItemPickerOptions { export type ItemPickerResult = { action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" }; -const MANAGE_HASH = "#/m02-master-template?layer=personal"; +/** 관리 화면 진입 해시(PLAN 27-1d) — 구조물 조합 · 개인 뿌리가 열린 채 */ +const MANAGE_HASH = `#/m02-master-template?${new URLSearchParams({ open: "구조물조합", root: "personal" })}`; const labels = (): TreeLabels => ({ layers: { diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index bff3311c..584fb857 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -68,7 +68,7 @@ export const ui_locales_m2 = { ], M02_CopyToPersonal: ["개인용으로 복사", "Copy to personal"], M02_CopyPickFirst: ["복사할 열을 구조물 조합에서 먼저 고름", "Pick a column in the table first"], - M02_CopyDone: ["개인용으로 복사함 — 구조물 관리(개인)에서 고침", "Copied — edit it in your own"], + M02_CopyDone: ["개인용으로 복사함 — 「개인」 뿌리에서 고침", "Copied — edit it in your own"], M02_CopyFailed: ["복사 안 됨 — {value}", "Copy failed — {value}"], M02_NeedCompany: [ "회사부터 등록해야 구조물 조합을 만들 수 있음", @@ -98,7 +98,11 @@ export const ui_locales_m2 = { M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"], M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"], M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"], - M02_TreeTitle: ["항목 나무", "Item tree"], + M02_TreeTitle: ["구조물 라이브러리", "Structure library"], + M02_RootMine: ["내 것", "Mine"], + M02_RootShared: ["회사 공용", "Company shared"], + M02_RootReadOnly: ["읽기", "read-only"], + M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], M02_TreeSearch: ["항목 찾기", "Find item"], M02_TreeEmptyEdit: [ "항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함",