"""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 「회사부터 등록」 옛 길(`/api/m02/structures/…` 등 · `M02_MasterTemplete_Router.py`)은 시스템 층 별칭으로 그대로. 회사 · 개인 층은 처음 부를 때 빈 뼈대를 만듦(`Items.ensure`). """ from __future__ import annotations from pathlib import Path from typing import Any, Literal 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_MasterTemplete_Branch as branch 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_Template_Layers as layers from M02_MasterTemplete.M02_MasterTemplete_Router import ( BranchBody, MaterialBranchBody, MatsetBody, UseComboBody, ) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) Layer = Literal["system", "company", "personal"] Session = dict[str, Any] NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음" class CopyItemBody(BaseModel): from_layer: Literal["system", "company"] from_owner: str | None = None 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.""" 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) return folder def _call(folder: Path, fn, *args): with store.at(folder): try: return fn(*args) except store.StoreError as e: raise HTTPException(status_code=e.status, detail=e.detail) from e # ── 항목 목록 · 복사 ─────────────────────────────────── 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: return {"layer": layer, "owner": None, "items": []} # 회사 없음 = 빈 그룹 folder = _root(session, layer, who) return {"layer": layer, "owner": who, "items": items_store.items(folder)} @router.get("/items") def get_items( layer: Layer = "system", owner: str | None = None, all: bool = False, # noqa: A002 — 21-1 약속 이름 `?all=1` session: Session = Depends(verify_session), ): """21-1 — 한 층이면 `[{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, 도번, 면적번, 재료번}]` · `?all=1` 이면 세 층 `[{layer, owner, items}]`(회사 · 개인은 세션 것 · 회사 없으면 빈 목록).""" if all: return [_listing(session, one, None) for one in ("system", "company", "personal")] return _listing(session, layer, owner)["items"] @router.post("/layers/personal/items/copy") def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> dict: """회사 · 마스터 항목 → 내 개인 층 새 항목(열 · 세 파일 · 자재 조합 줄 · 새 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) ) origin = {"layer": body.from_layer, "owner": owner, "key": body.key} try: return items_store.copy_item(source, target, body.key, origin) except store.StoreError as e: raise HTTPException(status_code=e.status, detail=e.detail) from e # ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── @router.get("/layers/{layer}/structures/numbers") def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.structure_numbers) @router.get("/layers/{layer}/structure-combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.read_combos) @router.post("/layers/{layer}/structures/{column}") def post_structure( layer: Layer, column: str, owner: str | None = None, session: Session = Depends(verify_session) ): return _call(_root(session, layer, owner, write=True), store.create_structure, column) @router.post("/layers/{layer}/structures/{column}/combo") def post_combo( layer: Layer, column: str, body: BranchBody, owner: str | None = None, session: Session = Depends(verify_session), ): """일위대가 조합 갈라 내기 — 새 UA 는 M01 시스템 마스터에 생기므로 SYSTEM_ADMIN 만.""" folder = _root(session, layer, owner, write=True) if not _admin(session): raise HTTPException(status_code=403, detail="일위대가 조합은 시스템 관리자만 새로 만듦.") return _call( folder, branch.branch_combo, column, body.본뜬키, body.담은로직, body.이름, body.판 ) @router.post("/layers/{layer}/structures/{column}/material-combo") def post_material_combo_branch( layer: Layer, column: str, body: MaterialBranchBody, owner: str | None = None, session: Session = Depends(verify_session), ): folder = _root(session, layer, owner, write=True) args = (column, body.본뜬키, body.담은자재, body.이름, body.판) return _call(folder, branch.branch_material_combo, *args) @router.post("/layers/{layer}/structures/{column}/use-combo") def post_use_combo( layer: Layer, column: str, body: UseComboBody, owner: str | None = None, session: Session = Depends(verify_session), ): folder = _root(session, layer, owner, write=True) return _call(folder, branch.use_combo, column, body.종류, body.키, body.판) @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, kind: Literal["structures", "area", "material"], column: str, source: str = Query(alias="from"), owner: str | None = None, session: Session = Depends(verify_session), ): folder = _root(session, layer, owner, write=True) part = "structure" if kind == "structures" else kind return _call(folder, store.copy_from, part, column, source) @router.get("/layers/{layer}/material-combos") def get_matsets(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), matset.listing) @router.get("/layers/{layer}/material-combos/{key}") def get_matset( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ): return _call(_root(session, layer, owner), matset.one, key) @router.post("/layers/{layer}/material-combos") def post_matset( layer: Layer, body: MatsetBody, owner: str | None = None, session: Session = Depends(verify_session), ): return _call(_root(session, layer, owner, write=True), matset.create, body.줄, body.판) @router.put("/layers/{layer}/material-combos/{key}") def put_matset( layer: Layer, key: str, body: MatsetBody, owner: str | None = None, session: Session = Depends(verify_session), ): return _call(_root(session, layer, owner, write=True), matset.edit, key, body.줄, body.판) @router.delete("/layers/{layer}/material-combos/{key}") def delete_matset( layer: Layer, key: str, 판: str = "", owner: str | None = None, session: Session = Depends(verify_session), ): return _call(_root(session, layer, owner, write=True), matset.delete, key, 판)