feat(db_management): 프로젝트 DB 행 백업 · 되살리기 도구 + 복제 도구에 참여자 · 지형층 · 주인 지정 (402-1)

- tools_project_backup.py: projects 와 연쇄로 지워지는 표 21개 덤프(JSON · id 그대로) · 고른 프로젝트만 되살리기(한 트랜잭션) · 읽기 확인
- tools_clone_project.py: project_members · terrain_layers 복제 누락 메움 · --owner · --members 선택

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-17 21:36:49 +09:00
co-authored by Claude Opus 5.5
parent 614f12bb88
commit bd96e390b9
2 changed files with 207 additions and 4 deletions
+33 -4
View File
@@ -7,9 +7,9 @@
⚠ 다만 **복제 중에 원본이 바뀌면 창마다 복제본이 달라진다** — 원본을 얼린 뒤 뜰 것.
무엇을 뜨나
DB projects · input_files · processed_point_cloud · surface_models · routes ·
route_points · route_statistics · longitudinal_sections · cross_sections ·
project_workflow_stages
DB projects · project_members · input_files · processed_point_cloud · surface_models ·
terrain_layers · routes · route_points · route_statistics · longitudinal_sections ·
cross_sections · project_workflow_stages
⚠ 자동증가 id 는 **새로 받고** 참조(외래키)를 새 id 로 다시 이어 붙인다.
파일 저장소 폴더 통째(robocopy). 경로가 프로젝트 루트 기준 상대라 그대로 쓰인다.
치환 파일 안에 박힌 **옛 프로젝트 UUID**, 초기값 스냅숏의 **surface_model_id**.
@@ -19,6 +19,7 @@
쓰는 법
./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>"
[--owner <사용자 id>] [--members <id,id>] (없으면 원본 주인 · 참여자 그대로)
"""
import argparse
@@ -42,6 +43,8 @@ def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).")
parser.add_argument("source_id", help="원본 프로젝트 UUID")
parser.add_argument("new_name", help="새 프로젝트 이름")
parser.add_argument("--owner", type=int, help="새 주인 사용자 id(없으면 원본 주인)")
parser.add_argument("--members", help="참여자 사용자 id 쉼표 목록(없으면 원본 참여자)")
return parser.parse_args()
@@ -87,6 +90,15 @@ async def main() -> None:
"SELECT * FROM surface_models WHERE project_id = %s ORDER BY id",
(SOURCE_ID,),
)
members = await _rows(
cursor, "SELECT * FROM project_members WHERE project_id = %s", (SOURCE_ID,)
)
layers = await _rows(
cursor,
"SELECT t.* FROM terrain_layers t JOIN surface_models s"
" ON s.id = t.surface_model_id WHERE s.project_id = %s ORDER BY t.id",
(SOURCE_ID,),
)
routes = await _rows(
cursor, "SELECT * FROM routes WHERE project_id = %s ORDER BY id", (SOURCE_ID,)
)
@@ -158,8 +170,18 @@ async def main() -> None:
project["id"] = new_id
project["name"] = NEW_NAME
project["storage_path"] = new_storage
if ARGS.owner is not None:
project["user_id"] = ARGS.owner
await _insert(cursor, "projects", project)
member_ids = (
[int(user) for user in ARGS.members.split(",") if user.strip()]
if ARGS.members is not None
else [row["user_id"] for row in members]
)
for user in member_ids:
await _insert(cursor, "project_members", {"project_id": new_id, "user_id": user})
file_map: dict[int, int] = {}
for row in input_files:
row = dict(row)
@@ -186,6 +208,12 @@ async def main() -> None:
)
model_map[old] = await _insert(cursor, "surface_models", row)
for row in layers:
row = dict(row)
row.pop("id")
row["surface_model_id"] = model_map[row["surface_model_id"]]
await _insert(cursor, "terrain_layers", row)
route_map: dict[int, int] = {}
for row in routes:
row = dict(row)
@@ -228,7 +256,8 @@ async def main() -> None:
await _insert(cursor, "project_workflow_stages", row)
await connection.commit()
print(
f"DB 복제 완료: 입력파일 {len(file_map)} · 지표면 {len(model_map)}"
f"DB 복제 완료: 참여자 {len(member_ids)} · 입력파일 {len(file_map)}"
f" · 지표면 {len(model_map)} · 지형층 {len(layers)}"
f" · 노선 {len(route_map)}"
f" · 정점 {len(points)} · 횡단 {len(crosses)} · 단계 {len(stages)}"
)