Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_6
- 충돌 Router_Items 갈라 내기 권한 — sub6 쪽(층 쓰기 권한 · 시스템 관리자 한정 뺌 · PLAN 27-2c) - Router_Combos 가 M02_Layer_Access(own_owner) 로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
@@ -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
|
||||
@@ -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 = <T>(path: string, init: RequestInit = {}): Promise<T> =>
|
||||
call<T>(`${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 = <T>(path: string, init: RequestInit = {}): Promise<T> =>
|
||||
call<T>(
|
||||
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<TemplateInfo[]> => {
|
||||
const got = await call<TemplateInfo[] | { 양식: TemplateInfo[] }>(
|
||||
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<ItemRow[]> =>
|
||||
call<ItemRow[]>(`/items?layer=${layer}`);
|
||||
call<ItemRow[]>(owned(layer, `/items?layer=${layer}`));
|
||||
|
||||
export const fetchItemInputs = (layer: Layer, key: string): Promise<ItemInputs> =>
|
||||
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<TreeView> => 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<TreeView> =>
|
||||
call(treePath(layer, owner));
|
||||
|
||||
/** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */
|
||||
export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise<TreeView> =>
|
||||
call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
|
||||
export const editTree = (
|
||||
layer: Layer,
|
||||
edit: TreeEdit,
|
||||
판: string,
|
||||
owner: string | null = null,
|
||||
): Promise<TreeView> =>
|
||||
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<LayerOwner[]> =>
|
||||
call(`/layers/${layer}/owners${company ? `?company=${enc(company)}` : ""}`);
|
||||
|
||||
@@ -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<string, string> => ({
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""M02 일위대가 조합 층 API(PLAN 27-2a) — `/api/m02/layers/{layer}/combos`(옛 `/api/m01/combo*`).
|
||||
|
||||
⚠ 등록은 `main.py`(로그인만) · 층 권한은 `Router_Items._root` 한 규칙:
|
||||
⚠ 등록은 `main.py`(로그인만) · 층 권한은 `M02_Layer_Access`(`Router_Items._root`) 한 규칙:
|
||||
system 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자(마스터 조합 · 키 UA)
|
||||
company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######)
|
||||
personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######)
|
||||
@@ -18,14 +18,9 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import (
|
||||
Layer,
|
||||
Session,
|
||||
_call,
|
||||
_owner,
|
||||
_root,
|
||||
)
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"])
|
||||
|
||||
@@ -58,7 +53,7 @@ def get_all(session: Session = Depends(verify_session)) -> list[dict]:
|
||||
"""고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층."""
|
||||
out = []
|
||||
for layer in ("system", "company", "personal"):
|
||||
who = None if layer == "system" else _owner(session, layer, None)
|
||||
who = None if layer == "system" else access.own_owner(session, layer)
|
||||
if layer != "system" and who is None:
|
||||
out.append({"layer": layer, "owner": None, "판": "", "폴더": [], "줄": []})
|
||||
continue
|
||||
|
||||
@@ -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 27-2c): 새 조합은 그 층에(마스터 UA · 회사 UC · 개인 UP) · 층 쓰기 권한 ·
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 관리 화면 항목 나무 — 줄 모양은 공용 ui-tree · ui-sidebar-row */
|
||||
/* 관리 화면 구조물 라이브러리 — 줄 모양은 공용 ui-tree · ui-sidebar-row */
|
||||
.m02-tree {
|
||||
padding-top: var(--spacing-8);
|
||||
display: flex;
|
||||
|
||||
@@ -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<TreeView>;
|
||||
edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise<TreeView>;
|
||||
};
|
||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||
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<void> {
|
||||
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.알림;
|
||||
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel_Dialogs.ts
|
||||
* 항목 나무 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
|
||||
* 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
|
||||
* 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel").
|
||||
* ========================================================================== */
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_TreePanel_Model.ts
|
||||
* 관리 화면 항목 나무의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 ·
|
||||
* 관리 화면 구조물 라이브러리의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 ·
|
||||
* 폴더 목록. 항목 경로는 폴더 이름 배열 + 마지막 마디가 항목 이름(PLAN 26-1 · 깊이 자유).
|
||||
* ========================================================================== */
|
||||
|
||||
|
||||
@@ -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[]>,
|
||||
): HTMLElement {
|
||||
const kids = el("div", { className: "ui-tree__kids" });
|
||||
kids.hidden = !open;
|
||||
let loaded = false;
|
||||
const fill = async (): Promise<void> => {
|
||||
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<HTMLElement[]> => {
|
||||
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] });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
|
||||
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<void> => {
|
||||
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
|
||||
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<void>
|
||||
|
||||
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<void>
|
||||
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") };
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
|
||||
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<HTMLElement>("button")?.click();
|
||||
};
|
||||
|
||||
void refresh();
|
||||
return { root, refresh, paint };
|
||||
return { root, refresh, paint, reveal };
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 항목 나무(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */
|
||||
/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 구조물 라이브러리(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */
|
||||
.m02-shell {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"}]
|
||||
@@ -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<string, string>" 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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""M02 관리 화면 항목 나무(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록.
|
||||
"""M02 관리 화면 구조물 라이브러리(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록.
|
||||
|
||||
항목 경로 = 폴더 이름 배열 + 마지막 마디가 항목 이름 · 찾는 글이 있으면 맞는 항목과 위 폴더만.
|
||||
Node 헬퍼(`helper_m02_treepanel.cjs`)가 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: {
|
||||
|
||||
@@ -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: [
|
||||
"항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함",
|
||||
|
||||
Reference in New Issue
Block a user