From ca5ae1171e2127561b5bed46184a0d34279a63d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 15:18:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore(db):=2054-44=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20=E2=80=94=20=EC=9B=90=EB=B3=B8=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC=20=E2=86=92=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EB=93=B1=EB=A1=9D=20=E2=86=92=20WF1=20?= =?UTF-8?q?=C2=B7=20=EC=9E=90=EB=8F=99=EC=84=A4=EA=B3=84=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EC=84=9C=EB=B2=84=20=ED=95=A8=EC=88=98=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=ED=98=B8=EC=B6=9C=20=C2=B7=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=81=94=20=C2=B7=20B05=20=EC=9D=BD=EA=B8=B0=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- db_management/tools_seed_projects.py | 232 +++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 db_management/tools_seed_projects.py diff --git a/db_management/tools_seed_projects.py b/db_management/tools_seed_projects.py new file mode 100644 index 000000000..848e130aa --- /dev/null +++ b/db_management/tools_seed_projects.py @@ -0,0 +1,232 @@ +"""검증 프로젝트 일괄 새로 만들기 — 같은 입력 원본으로 등록 → 입력 → 전처리 → 자동설계 체인. + +복제(`tools_clone_project.py`)와 다름 — 옛 결과를 뜨지 않고 **지금 로직으로 처음부터** 계산. +화면 없이 서버 함수를 직접 부름(로그인 없음 · 소유자는 등록 함수의 user_id). + +- 원본은 읽기만 — DB 행(등록 칸)과 `B03_FileInput/input` 파일을 복사해 씀. +- 이름이 이미 있으면(삭제 안 된 행) 건너뜀 — 다시 돌려도 이어서 만듦. +- 알림 메일 끔. + +쓰는 법 + ./venv/Scripts/python.exe db_management/tools_seed_projects.py \ + <원본 UUID> <결과 md> 이름:user_id ... +""" + +import asyncio +import json +import logging +import os +import shutil +import sys +import time +from pathlib import Path +from uuid import UUID + +os.environ["SEND_ANALYSIS_COMPLETION_EMAIL"] = "False" +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +import aiomysql + +import B03_FileInput.B03_FileInput_Service_WF1 as wf1 +from B02_ProjRegister.B02_ProjRegister_Repository import create_project +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import create_input_file +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _complete_file_input_if_ready, + _write_stage_metadata, +) +from B03_FileInput.B03_FileInput_Schema import UploadedFileResult +from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points +from B05_Profile.B05_Profile_Structures_Router import read_structures +from common_util.common_util_initial_snapshot import is_design_failed, read_design_failure +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import close_db_pool, get_db_pool, init_db_pool + +wf1.SEND_ANALYSIS_COMPLETION_EMAIL = False + + +async def _no_mail(**_: object) -> None: + return None + + +wf1.send_analysis_error_email = _no_mail +logger = logging.getLogger("seed") + +REGISTER_FIELDS = ( + "region", + "road_type", + "project_year", + "estimated_length_m", + "memo", +) +TITLE_FIELDS = ( + "client_org", + "project_number", + "work_amount", + "design_date", + "pm_user_id", + "field_lead_user_id", + "designer_user_id", + "logo_asset_id", + "route_start_m", + "route_end_m", +) + + +async def _one(sql: str, args: tuple) -> dict | None: + async with get_db_pool().acquire() as c, c.cursor(aiomysql.DictCursor) as cur: + await cur.execute(sql, args) + return await cur.fetchone() + + +async def _register_inputs(project_id: str, source_root: Path, source_files: list[dict]) -> int: + """원본 입력 파일을 같은 상대 경로로 복사하고 업로드 라우터와 같은 DB 등록을 한다.""" + pool = get_db_pool() + project_uuid = UUID(project_id) + row = await _one("SELECT storage_path FROM projects WHERE id=%s", (project_id,)) + project_root = Path(resolve_stored_project_path(row["storage_path"])) + results: list[UploadedFileResult] = [] + async with pool.acquire() as connection: + await connection.begin() + try: + for item in source_files: + relative_path = item["relative_path"] + destination = project_root / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + await asyncio.to_thread(shutil.copyfile, source_root / relative_path, destination) + metadata = await asyncio.to_thread(analyze_input_metadata, destination) + size = destination.stat().st_size + crs_epsg = metadata.get("epsg") + input_file_id = await create_input_file( + connection, + project_id=project_uuid, + file_type=item["file_type"], + original_filename=item["original_filename"], + relative_path=relative_path, + file_size_bytes=size, + upload_by=None, + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + results.append( + UploadedFileResult( + input_file_id=input_file_id, + original_filename=item["original_filename"], + file_type=item["file_type"], + relative_path=relative_path, + size_bytes=size, + metadata=metadata, + ) + ) + point_cloud_input_id = await _complete_file_input_if_ready(connection, project_uuid) + await connection.commit() + except Exception: + await connection.rollback() + raise + _write_stage_metadata(project_root / "B03_FileInput", project_uuid, results) + return point_cloud_input_id + + +async def _check_b05(project_id: str) -> str: + """B05 를 여는 두 읽기(구조물 · 관 지점)와 관 하나의 options.""" + structures = await read_structures(UUID(project_id)) + pipes = await get_pipe_points(UUID(project_id)) + if not isinstance(pipes, dict) or not hasattr(structures, "structures"): + return "B05 읽기 실패" + points = pipes.get("pipe_points") or [] + first = (points[0].get("options") if points else None) or {} + return ( + f"구조물 {len(structures.structures)} · 관 {len(points)} · " + f"첫 관 options {json.dumps(first, ensure_ascii=False)}" + ) + + +async def _seed(name: str, user_id: int, source: dict, source_files: list[dict]) -> dict: + existing = await _one( + "SELECT id FROM projects WHERE name=%s AND user_id=%s AND deleted_at IS NULL", + (name, user_id), + ) + if existing: + project_id = existing["id"] + return { + "name": name, + "user_id": user_id, + "id": project_id, + "note": "이미 있음", + "b05": await _check_b05(project_id), + } + started = time.monotonic() + created = await create_project( + user_id=user_id, + company_id=int(source["company_id"]), + name=name, + title_block={key: source[key] for key in TITLE_FIELDS}, + **{key: source[key] for key in REGISTER_FIELDS}, + ) + project_id = str(created["project_id"]) + logger.info("만듦 %s %s", name, project_id) + input_id = await _register_inputs( + project_id, + Path(resolve_stored_project_path(source["storage_path"])), + source_files, + ) + await wf1.trigger_wf1_analysis_and_email( + project_id=UUID(project_id), input_file_id=input_id, user_role="", started_by=user_id + ) + row = await _one("SELECT status, storage_path FROM projects WHERE id=%s", (project_id,)) + root = Path(resolve_stored_project_path(row["storage_path"])) + failure = read_design_failure(root) if is_design_failed(root) else "" + return { + "name": name, + "user_id": user_id, + "id": project_id, + "status": row["status"], + "failure": failure, + "snapshot": (root / "initial_snapshot").is_dir(), + "b05": await _check_b05(project_id), + "minutes": round((time.monotonic() - started) / 60, 1), + } + + +async def main(source_id: str, report: Path, targets: list[tuple[str, int]]) -> None: + await init_db_pool() + try: + source = await _one("SELECT * FROM projects WHERE id=%s", (source_id,)) + source_root = Path(resolve_stored_project_path(source["storage_path"])) + meta = json.loads((source_root / "B03_FileInput/metadata.json").read_text("utf-8")) + source_files = meta["files"] + rows = [] + for name, user_id in targets: + try: + result = await _seed(name, user_id, source, source_files) + except Exception as exc: + logger.exception("실패 %s", name) + result = {"name": name, "user_id": user_id, "id": "", "failure": repr(exc)} + logger.info("결과 %s", json.dumps(result, ensure_ascii=False)) + rows.append(result) + _write_report(report, source_id, rows) + finally: + await close_db_pool() + + +def _write_report(report: Path, source_id: str, rows: list[dict]) -> None: + lines = [ + f"# 새 검증 프로젝트 (원본 {source_id} 입력 · 새 로직 처음부터)", + "", + "| 이름 | 소유 user | 프로젝트 id | 상태 | 초기값 | B05 확인 | 실패 | 분 |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for r in rows: + lines.append( + f"| {r['name']} | {r['user_id']} | {r['id']} | {r.get('status', r.get('note', ''))} " + f"| {r.get('snapshot', '')} | {r.get('b05', '')} | {r.get('failure', '')} " + f"| {r.get('minutes', '')} |" + ) + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s") + pairs = [arg.rsplit(":", 1) for arg in sys.argv[3:]] + asyncio.run(main(sys.argv[1], Path(sys.argv[2]), [(n, int(u)) for n, u in pairs])) From 222a3cb1ff76677e977b91a85f4c8a1733ff7d49 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 15:27:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(dashboard):=2054-44c=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B7=A8=ED=96=A5=20=EC=A0=80=EC=9E=A5=20=EB=95=8C?= =?UTF-8?q?=20=EC=A7=80=EC=9B=8C=EC=A7=84=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20id=20=ED=82=A4=20=EB=B2=84=EB=A6=BC=20=E2=80=94=20?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=98=9B=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=EC=9D=98=20=EC=9E=AC=EC=98=A4=EC=97=BC=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=8B=9C=ED=97=98=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B01_Dashboard/B01_Dashboard_Repository.py | 16 ++++- .../tester/test_b01_ui_prefs_dead_project.py | 70 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_b01_ui_prefs_dead_project.py diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 658c042fb..8907556f1 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import shutil from datetime import datetime, timedelta from typing import Any @@ -547,10 +548,23 @@ async def get_user_ui_prefs(user_id: int) -> dict[str, Any]: return raw if isinstance(raw, dict) else {} +_UUID_IN_KEY = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + + async def save_user_ui_prefs(user_id: int, prefs: dict[str, Any]) -> None: - """화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄.""" + """화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄. + + 지워진 프로젝트 id 가 박힌 키는 버림 — 브라우저 사본이 옛 키를 다시 올리지 못하게. + """ pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: + keyed = {key: m.group(0) for key in prefs if (m := _UUID_IN_KEY.search(key))} + if keyed: + ids = sorted(set(keyed.values())) + holders = ", ".join(["%s"] * len(ids)) + await cursor.execute(f"SELECT id FROM projects WHERE id IN ({holders})", ids) + live = {row[0] for row in await cursor.fetchall()} + prefs = {k: v for k, v in prefs.items() if k not in keyed or keyed[k] in live} await cursor.execute( """INSERT INTO user_ui_prefs (user_id, prefs) VALUES (%s, %s) ON DUPLICATE KEY UPDATE prefs = VALUES(prefs)""", diff --git a/resources/tester/test_b01_ui_prefs_dead_project.py b/resources/tester/test_b01_ui_prefs_dead_project.py new file mode 100644 index 000000000..c1e1e98f5 --- /dev/null +++ b/resources/tester/test_b01_ui_prefs_dead_project.py @@ -0,0 +1,70 @@ +"""화면 취향 저장 — 지워진 프로젝트 id 가 박힌 키는 버림 · 나머지 키는 그대로.""" + +import asyncio +import json + +import B01_Dashboard.B01_Dashboard_Repository as repo + +LIVE = "226fc805-26c8-4f5a-9956-bacb200e10a5" +DEAD = "936be972-11bc-46c2-8bf3-b15d8de7df0d" + + +class _Cursor: + def __init__(self, store): + self.store = store + self.rows = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql, args): + if sql.startswith("SELECT id FROM projects"): + self.rows = [(i,) for i in args if i == LIVE] + else: + self.store["saved"] = json.loads(args[1]) + + async def fetchall(self): + return self.rows + + +class _Conn: + def __init__(self, store): + self.store = store + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + def cursor(self): + return _Cursor(self.store) + + async def commit(self): + pass + + +class _Pool: + def __init__(self, store): + self.store = store + + def acquire(self): + return _Conn(self.store) + + +def test_dead_project_keys_dropped(monkeypatch): + store = {} + monkeypatch.setattr(repo, "get_db_pool", lambda: _Pool(store)) + prefs = { + f"aislo:pref:routeedit-view:{LIVE}": "a", + f"aislo:pref:routeedit-view:{DEAD}": "b", + "aislo:pref:table-open": "true", + } + asyncio.run(repo.save_user_ui_prefs(3, prefs)) + assert store["saved"] == { + f"aislo:pref:routeedit-view:{LIVE}": "a", + "aislo:pref:table-open": "true", + }