Files
Aislo/B02_ProjRegister/B02_ProjRegister_Repository.py
T
eomsangdonandClaude Opus 5 4066504ba9 feat(B01): 시스템 로그에 대상·접속 정보 기록 + 1년 보관 정리 (2026-09-06 사용자 확정)
- 기록을 `common_util_audit.record_audit()` 한 곳으로 모음 — 여섯 자리에 흩어져 있던
  raw INSERT 제거
- 대상 식별자 칸 신설(`017_audit_log_detail.sql`, 적용 완료) — 기존 `resource_id` 는 INT 라
  프로젝트 UUID 를 못 담아 늘 NULL 이었음. 프로젝트 생성·수정·삭제·회사 생성이 대상을 남김
- 접속 주소·브라우저 기록 — 라우터가 요청을 넘겨 주고, 프록시 뒤에서는 X-Forwarded-For 우선
- 보관 기간 `AUDIT_LOG_RETENTION_DAYS` 기본 365일, 임시 보관함 정리 루프에 얹어 함께 정리
- 화면: 시스템 로그 표에 대상·접속 주소 열 추가(대상은 UUID 앞 8자만)

자체검증 — 프로젝트 생성·하드삭제를 실화면에서 돌려 두 줄 모두
`프로젝트 e1040640… · 127.0.0.1 · 2026-09-06 09:54/09:55` 로 남는 것 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:57:22 +09:00

164 lines
6.2 KiB
Python

"""B02_ProjRegister aiomysql Raw SQL 저장소."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
import aiomysql
from common_util.common_util_audit import record_audit
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2
from common_util.common_util_workflow import load_project_workflow
from common_util.common_util_workflow_state import initialize_project_stages
from config.config_db import get_db_pool
from config.config_system import STORAGE_BASE_DIR
def _build_project_storage(company_id: int, user_id: int, project_id: str) -> tuple[str, Path]:
relative_path = f"storage/{company_id}/{user_id}/{project_id}"
storage_root = Path(STORAGE_BASE_DIR).resolve()
project_root = (storage_root / str(company_id) / str(user_id) / project_id).resolve()
if storage_root not in (project_root, *project_root.parents):
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
return relative_path, project_root
def _initialize_project_storage(project_root: Path, project_id: str) -> None:
for stage, subdir in PROJECT_STORAGE_LAYOUT_V2:
(project_root / stage / subdir).mkdir(parents=True, exist_ok=True)
atomic_write_json(project_root / "workflow.json", load_project_workflow(project_root))
atomic_write_json(
project_root / "project_manifest.json",
{
"project_id": project_id,
"storage_version": 2,
"created_at": datetime.utcnow().isoformat(timespec="seconds"),
"stages": [f"{stage}/{subdir}" for stage, subdir in PROJECT_STORAGE_LAYOUT_V2],
},
)
async def create_project(
*,
request: Any | None = None,
user_id: int,
company_id: int,
name: str,
region: str,
road_type: str,
project_year: int,
estimated_length_m: float | None,
memo: str | None,
title_block: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""신규 프로젝트를 DB에 저장하고 워크플로우 저장소를 초기화한다."""
project_id = str(uuid4())
fields = title_block or {}
storage_path, project_root = _build_project_storage(company_id, user_id, project_id)
now = datetime.utcnow()
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
try:
await connection.begin()
await cursor.execute(
"""
INSERT INTO projects (
id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, route_start_m, route_end_m,
memo, status,
crs_epsg, storage_path, created_at, updated_at,
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
project_id,
user_id,
company_id,
name,
region,
road_type,
project_year,
estimated_length_m,
fields.get("route_start_m"),
fields.get("route_end_m"),
memo,
storage_path,
now,
now,
fields.get("client_org"),
fields.get("project_number"),
fields.get("work_amount"),
fields.get("design_date"),
fields.get("pm_user_id"),
fields.get("field_lead_user_id"),
fields.get("designer_user_id"),
fields.get("logo_asset_id"),
),
)
# 만든 사람은 곧 참여자다 (2026-09-06 사용자 확정) — 참여자는 수정 권한을 가진다.
await cursor.execute(
"INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)",
(project_id, user_id),
)
# 워크플로우 단계별 상태 초기화 시드
await initialize_project_stages(cursor, project_id)
await record_audit(
cursor,
actor_id=user_id,
action="PROJECT_CREATE",
resource_type="project",
resource_ref=project_id,
request=request,
)
_initialize_project_storage(project_root, project_id)
await connection.commit()
except Exception:
await connection.rollback()
raise
await cursor.execute(
"""
SELECT id AS project_id, name, region, road_type, project_year,
estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
FROM projects
WHERE id = %s
""",
(project_id,),
)
row = await cursor.fetchone()
if not row:
raise RuntimeError("생성된 프로젝트를 다시 조회할 수 없습니다.")
return dict(row)
async def get_project_by_id(project_id: str) -> dict[str, Any] | None:
"""프로젝트 단건 조회."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT id AS project_id, user_id, company_id, name, region, road_type,
project_year, estimated_length_m, route_start_m, route_end_m,
memo, status, storage_path,
DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at
FROM projects
WHERE id = %s AND deleted_at IS NULL
""",
(project_id,),
)
return await cursor.fetchone()