diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 640d6942a..0ba02af12 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -7,7 +7,9 @@ from __future__ import annotations +import json from collections.abc import Callable +from datetime import datetime from pathlib import Path from typing import Any @@ -32,6 +34,26 @@ def history_path(version: int) -> Path: return Path(schema.DEFINITION_PATH).parent / HISTORY / f"판_{version}.json" +def history() -> list[dict[str, Any]]: + """지난 판 목록 `[{판, 저장일}]` — 이력 `판_N.json` · 저장일 = 파일 고친 때(분까지).""" + folder = history_path(1).parent + rows = [] + for path in folder.glob("판_*.json") if folder.is_dir() else []: + number = path.stem.removeprefix("판_") + if number.isdigit(): + when = datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + rows.append({"판": int(number), "저장일": when}) + return sorted(rows, key=lambda r: r["판"], reverse=True) + + +def read_version(version: int) -> dict[str, Any]: + """지난 판 한 벌(읽기만) — 없으면 404.""" + path = history_path(version) + if not path.is_file(): + raise store.StoreError(404, f"판 {version} 이력이 없음") + return json.loads(path.read_text("utf-8")) + + def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, Any]]]: """(틀린 것 · 종류 → {옵션 키: 옵션}) — 종류 꼴 · 키 겹침 · 구조물군 있음.""" errors: list[str] = [] diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Router.py b/M02_MasterTemplete/StructOptions/M02_SO_Router.py index 0dbfc8a22..b18945fdb 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Router.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Router.py @@ -84,6 +84,20 @@ def get_structure_definition(session=Depends(verify_session)) -> dict: return _call(definition.read) +@read_router.get("/structure-definition/history") +def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]: + """지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2).""" + access.require(session, "system", None) + return _call(definition.history) + + +@read_router.get("/structure-definition/history/{version}") +def get_structure_definition_at(version: int, session=Depends(verify_session)) -> dict: + """지난 판 한 벌(읽기만) — 없으면 404.""" + access.require(session, "system", None) + return _call(definition.read_version, version) + + @read_router.put("/structure-definition") def put_structure_definition(body: DefinitionBody, session=Depends(verify_session)) -> dict: """정의 고쳐 씀 — 시스템 관리자만(403) · 낡은 판 409 · 검사 422 · 판 +1 · 앞 판은 이력.""" diff --git a/resources/tester/test_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py new file mode 100644 index 000000000..c344372c5 --- /dev/null +++ b/resources/tester/test_m02_structure_def_screen.py @@ -0,0 +1,123 @@ +"""M02 구조물 정의 편집 화면(PLAN 48-2) — 지난 판 길 · 화면이 보내는 꼴을 서버가 받음. + +못박는 것 + - 지난 판 목록 `[{판, 저장일}]` 새 판부터 · 한 벌 읽기 · 없는 판 404 · 읽기는 누구나. + - 화면이 고친 정의(이름 · 순서 · 안 씀 · 기준측점 · 선택지 이름 · 배치 · 그룹 형상 · 규칙)를 + 그대로 PUT 하면 판 +1 · 같은 값으로 다시 읽힘. + - 시스템 관리자 아니면 PUT 403(화면은 읽기만). + - 마스터 정본은 안 씀(사본). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B05_Profile import B05_Profile_Structures_Schema as schema +from common_util.common_util_auth import verify_session +from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router + +URL = "/api/m02/structure-definition" +ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1} +USER = {"role": "USER", "user_id": 2, "company_id": 1} + + +@pytest.fixture +def copy(tmp_path: Path, monkeypatch) -> Path: + path = tmp_path / "구조물정의.json" + path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return path + + +def _client(session: dict) -> TestClient: + app = FastAPI() + app.include_router(read_router) + app.dependency_overrides[verify_session] = lambda: session + return TestClient(app) + + +def _screen_edit(doc: dict) -> dict: + """화면이 하는 고침 한 벌 — `M02_SO_Def*.ts` 가 만드는 꼴 그대로.""" + pipe = next(t for t in doc["types"] if t["type_id"] == "pipe") + pipe["name"] = "배수관(고침)" + kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind") + kind["label"] = "관 종류" + kind["choices"] = ["흄관(고침)" if c == "흄관" else c for c in kind["choices"]] + pipe["options"].append( + { + "key": "opt_ab12cd", + "label": "새 옵션 1", + "input": "select", + "choices": ["새 선택지 1", "새 선택지 2"], + "default": None, + } + ) + pipe["options"][1]["enabled"] = False # 안 씀 + first = list(doc["구조물군"]) + doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]} + doc["종류"].setdefault("pipe", {})["기준측점"] = False + doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}} + doc["표시"]["pipe"] = {"inlet_type": False} + doc["그룹"]["pipe"][1]["형상"] = { + "길이": {"받기": True, "뜻": "유입 보호공 연장"}, + "높이": {"받기": False, "뜻": ""}, + } + doc["규칙"] = [ + { + "종류": "pipe", + "만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]}, + "그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"}, + }, + { + "종류": "pipe", + "만약": {"옵션": "inlet_type", "값들": ["집수정"]}, + "그러면": {"옵션": "opt_ab12cd", "보이기": False}, + }, + ] + return doc + + +def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy) -> None: + admin = _client(ADMIN) + doc = admin.get(URL).json() + assert admin.get(f"{URL}/history").json() == [] + sent = _screen_edit(json.loads(json.dumps(doc))) + res = admin.put(URL, json={"판": doc["판"], "정의": sent}) + assert res.status_code == 200, res.text + got = res.json() + assert got["판"] == doc["판"] + 1 + for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"): + assert got[key] == sent[key], key + assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로 + rows = admin.get(f"{URL}/history").json() + assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16 + old = admin.get(f"{URL}/history/{doc['판']}").json() + assert old == doc + assert admin.get(f"{URL}/history/999").status_code == 404 + + +def test_지난_판은_누구나_읽고_쓰기는_시스템_관리자만(copy) -> None: + user = _client(USER) + doc = user.get(URL).json() + assert user.get(f"{URL}/history").status_code == 200 + assert user.put(URL, json={"판": doc["판"], "정의": _screen_edit(doc)}).status_code == 403 + assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"] + + +def test_화면이_못_거른_틀린_규칙은_422_안_씀(copy) -> None: + admin = _client(ADMIN) + doc = admin.get(URL).json() + doc["규칙"] = [ + { + "종류": "pipe", + "만약": {"옵션": "pipe_kind", "값들": ["없음"]}, + "그러면": {"옵션": "inlet_type"}, + } + ] + assert admin.put(URL, json={"판": doc["판"], "정의": doc}).status_code == 422 + assert json.loads(copy.read_text("utf-8"))["판"] == doc["판"] diff --git a/resources/tester/test_m02_structure_option_groups.py b/resources/tester/test_m02_structure_option_groups.py index 7e3ca8312..144ac9314 100644 --- a/resources/tester/test_m02_structure_option_groups.py +++ b/resources/tester/test_m02_structure_option_groups.py @@ -102,8 +102,10 @@ def test_틀린_그룹은_422_낡은_판은_409(client) -> None: assert res.status_code == 200 and res.json()["그룹"] == {} -def test_좌측_옵션_보이기는_시스템_관리자만() -> None: +def test_좌측_정의_줄은_시스템_층에_늘_고쳐_씀은_시스템_관리자만() -> None: + """PLAN 48-2 — 다른 역할은 읽기만(화면이 칸을 잠금 · 서버 PUT 403).""" side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8") - assert "옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null" in side - ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8") - assert "buildGroupBlock(" in ui and "buildPlaceSelect(" in ui + assert "옵션: section(options.title, options.body)," in side + folder = ROOT / "M02_MasterTemplete/StructOptions" + assert "buildGroupBlock(" in (folder / "M02_SO_Def.ts").read_text("utf-8") + assert "buildPlaceSelect(" in (folder / "M02_SO_Def_Options.ts").read_text("utf-8") diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 971a94575..0bf9bd334 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -123,12 +123,10 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert "matset" not in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 + # 화면은 정의 한 벌로 읽고 씀(PLAN 48-2) · 보이기 체크는 정의 화면 옵션 줄 api = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Api.ts").read_text("utf-8") - assert ( - 'call("/layers/system/structure-options")' in api - and 'call("/structure-options", { method: "PUT"' in api - ) - ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8") + assert 'call("/structure-definition")' in api + ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts").read_text("utf-8") assert 'o.phase === "detail"' in ui # B06 전용 표시