"""M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」. ⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서: system 읽기만(고치기는 `/api/m02/templates` 시스템 관리자 길) company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자) personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기) project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀 """ from __future__ import annotations import asyncio import logging from pathlib import Path from typing import Any, Literal from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, ConfigDict, Field from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_workflow_route_context, ) 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_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"]) Layer = Literal["system", "company", "personal", "project"] class SaveBody(BaseModel): 판: str | None = None 문서: dict[str, Any] class TargetBody(BaseModel): 종류: str | None = None 이름: str | None = None class ApplyBody(BaseModel): model_config = ConfigDict(populate_by_name=True) from_: Layer = Field(alias="from") user_id: int | None = None project_id: str | None = None 종류: str | None = None 이름: str | None = None class SaveAsBody(BaseModel): to: Literal["personal", "company"] 종류: str 이름: str # ── DB (시험이 바꿔 끼움) ───────────────────────────── async def _project_row(project_id: str) -> dict[str, Any] | None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( """SELECT id, name, company_id, user_id, storage_path FROM projects WHERE id = %s AND deleted_at IS NULL""", (str(project_id),), ) row = await cursor.fetchone() if not row: return None keys = ("id", "name", "company_id", "user_id", "storage_path") return dict(zip(keys, row, strict=True)) async def _user_company(user_id: int) -> int | None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( "SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL", (user_id,) ) row = await cursor.fetchone() return row[0] if row else None async def _company_users(company_id: int) -> 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 users WHERE company_id = %s AND deleted_at IS NULL ORDER BY id", (company_id,), ) rows = await cursor.fetchall() return [{"user_id": row[0], "name": row[1]} for row in rows] async def _company_projects(company_id: int) -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( """SELECT id, name, storage_path FROM projects WHERE company_id = %s AND deleted_at IS NULL ORDER BY created_at DESC""", (company_id,), ) rows = await cursor.fetchall() return [{"project_id": str(row[0]), "name": row[1], "storage_path": row[2]} for row in rows] async def _cross_designs(project_id: str) -> list[dict[str, Any]]: """최신 노선의 B06 횡단 설계 — 못 읽으면 빈 목록(관 연장이 빈칸으로 섬 · 0 아님).""" try: context = await run_with_connection(get_workflow_route_context, UUID(str(project_id))) route_id = int((context or {}).get("route_id") or 0) return await run_with_connection(get_cross_section_designs, route_id) if route_id else [] except Exception: logger.exception("M02 채운 표 — 횡단 설계 조회 실패: project_id=%s", project_id) return [] # ── 권한 · 자리 ─────────────────────────────────────── 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")) async def _project(session: dict[str, Any], project_id: str | None) -> dict[str, Any]: """같은 회사 프로젝트 한 건 + `root`(실경로). 아니면 403/404.""" if not project_id: raise HTTPException(status_code=400, detail="project_id 가 필요합니다.") row = await _project_row(project_id) if row is None: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") if not _is_system_admin(session) and row["company_id"] != session.get("company_id"): raise HTTPException(status_code=403, detail="다른 회사의 프로젝트입니다.") if not row.get("storage_path"): raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") row["root"] = Path(resolve_stored_project_path(row["storage_path"])) return row def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int: company_id = project["company_id"] if project else session.get("company_id") if company_id is None: raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") return int(company_id) async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None: if user_id == session.get("user_id"): return if await _user_company(user_id) != company_id: raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.") async def _layer_dir( session: dict[str, Any], layer: str, *, project_id: str | None, user_id: int | None = None, write: bool = False, ) -> Path: """층 폴더 — 읽기·쓰기 권한까지 여기서 가름.""" if layer == "system": if write: raise HTTPException(status_code=403, detail="시스템 양식은 마스터 템플릿 화면에서만.") return layers.system_dir() 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 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 layers.personal_dir(company_id, owner) raise HTTPException(status_code=404, detail="없는 층입니다.") def _bad(error: ValueError) -> HTTPException: return HTTPException(status_code=400, detail=str(error)) # ── 층 길 ───────────────────────────────────────────── @router.get("/layers/{layer}/templates") async def list_layer( layer: Layer, project_id: str | None = Query(None), user_id: int | 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) rows = await asyncio.to_thread(layers.list_templates, folder) manifest = await asyncio.to_thread(layers.read_manifest, folder) for row in rows: row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") return {"층": layer, "양식": rows} @router.get("/layers/{layer}/templates/{kind}/{name}") async def read_layer_template( layer: Layer, kind: str, name: str, project_id: str | None = Query(None), user_id: int | 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) try: found = await asyncio.to_thread(layers.read_template, folder, kind, name) except ValueError as error: raise _bad(error) from error if found is None and layer == "project": # 옛 프로젝트(사본 없음) — 시스템 양식으로 떨어짐 · 저장하면 그때 작업본이 생김 found = await asyncio.to_thread(layers.read_template, layers.system_dir(), kind, name) if found is not None: found.update({"층": "system", "판": None}) return found if found is None: raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.") found["층"] = layer found["출처"] = layers.read_manifest(folder).get(f"{kind}/{name}") return found @router.put("/layers/{layer}/templates/{kind}/{name}") async def save_layer_template( layer: Layer, kind: str, name: str, body: SaveBody, project_id: 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) document = body.문서 try: layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀 except ValueError as error: raise _bad(error) from error if kind == "table" and fill.is_fillable(document): # 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음) document = fill.strip_design(document) try: version = await asyncio.to_thread( layers.write_template, folder, kind, name, document, version=body.판, check_version=True, ) except layers.StaleTemplate as error: raise HTTPException( status_code=409, detail={"message": str(error), "판": error.current} ) from error except ValueError as error: raise _bad(error) from error return {"종류": kind, "이름": name, "판": version, "층": layer} # ── 프로젝트 길 ─────────────────────────────────────── @router.post("/projects/{project_id}/templates/reset") async def reset_project_templates( project_id: str, body: TargetBody | None = None, session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: project = await _project(session, project_id) target = body or TargetBody() try: done = await asyncio.to_thread( layers.reset_project, project["root"], kind=target.종류, name=target.이름 ) except ValueError as error: raise _bad(error) from error return {"초기화": done} @router.post("/projects/{project_id}/templates/apply") async def apply_project_templates( project_id: str, body: ApplyBody, session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: """다른 층 양식을 작업본에 덮어씀 — [회사 양식 적용] · [양식 가져오기]. `_initial/` 은 그대로 — 초기화 기준은 안 바뀜. """ project = await _project(session, project_id) ref: dict[str, Any] = {} if body.from_ == "project": if not body.project_id or body.project_id == project_id: raise HTTPException(status_code=400, detail="가져올 다른 프로젝트를 고르세요.") other = await _project(session, body.project_id) if other["company_id"] != project["company_id"]: raise HTTPException(status_code=403, detail="같은 회사 프로젝트만 가져옵니다.") source = layers.project_dir(other["root"]) ref = {"project_id": body.project_id, "프로젝트": other.get("name")} elif body.from_ == "personal": owner = body.user_id if body.user_id is not None else int(session["user_id"]) await _same_company_user(session, owner, int(project["company_id"])) source = layers.personal_dir(project["company_id"], owner) ref = {"user_id": owner} elif body.from_ == "company": source = layers.company_dir(project["company_id"]) else: source = layers.system_dir() try: done = await asyncio.to_thread( layers.copy_templates, source, layers.project_dir(project["root"]), source_layer=body.from_, kind=body.종류, name=body.이름, source_ref=ref, ) except ValueError as error: raise _bad(error) from error if not done: raise HTTPException(status_code=404, detail="가져올 양식이 없습니다.") return {"적용": done, "from": body.from_} @router.post("/projects/{project_id}/templates/save-as") async def save_project_template_as( project_id: str, body: SaveAsBody, session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: """작업본 한 벌을 [내 양식으로 저장] · [회사 공식으로 저장].""" project = await _project(session, project_id) if body.to == "company" and not _is_company_admin(session): raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.") if body.to == "company": target = layers.company_dir(project["company_id"]) else: target = layers.personal_dir(project["company_id"], session["user_id"]) try: done = await asyncio.to_thread( layers.copy_templates, layers.project_dir(project["root"]), target, source_layer="project", kind=body.종류, name=body.이름, source_ref={"project_id": project_id, "프로젝트": project.get("name")}, ) except ValueError as error: raise _bad(error) from error if not done: raise HTTPException(status_code=404, detail="프로젝트 작업본에 그 양식이 없습니다.") return {"저장": done, "to": body.to} @router.get("/projects/{project_id}/sources") async def list_sources( project_id: str, session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: """가져올 수 있는 것 — 시스템 · 회사 공식 · 같은 회사 사람 개인 · 같은 회사 다른 프로젝트.""" project = await _project(session, project_id) company_id = int(project["company_id"]) def _people(users: list[dict[str, Any]]) -> list[dict[str, Any]]: rows = [] for user in users: found = layers.list_templates(layers.personal_dir(company_id, user["user_id"])) if found: rows.append({**user, "양식": found}) return rows def _projects(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: rows = [] for other in projects: if other["project_id"] == str(project_id) or not other.get("storage_path"): continue try: root = Path(resolve_stored_project_path(other["storage_path"])) except ValueError: continue found = layers.list_templates(layers.project_dir(root)) if found: rows.append( {"project_id": other["project_id"], "name": other["name"], "양식": found} ) return rows users = await _company_users(company_id) projects = await _company_projects(company_id) return { "system": await asyncio.to_thread(layers.list_templates, layers.system_dir()), "company": await asyncio.to_thread(layers.list_templates, layers.company_dir(company_id)), "personal": await asyncio.to_thread(_people, users), "project": await asyncio.to_thread(_projects, projects), } @router.get("/projects/{project_id}/tables/{name}/filled") async def filled_table( project_id: str, name: str, session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: """설계값을 채운 표 문서 — 저장 안 함(5장 ③ 서버 단독) · `결과` = 계산 열(없으면 null).""" project = await _project(session, project_id) try: found = await asyncio.to_thread( layers.read_template, layers.project_dir(project["root"]), "table", name ) if found is None: found = await asyncio.to_thread( layers.read_template, layers.system_dir(), "table", name ) except ValueError as error: raise _bad(error) from error if found is None: raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.") lengths = fill.pipe_lengths_from_designs(await _cross_designs(project_id)) document = await asyncio.to_thread(fill.fill_table, project["root"], found["문서"], lengths) result = await asyncio.to_thread(fill.recalc, document) return {"이름": name, "판": found["판"], "문서": document, "결과": result}