feat(528): 신규 프로젝트 그룹 공통 백엔드 — 종류 칸 · 산림청 판정 · D01 · E01 생성 API · 공유 GET 만 · 가입 go.kr = 산림청
- DB 020(추가만): projects 종류 · 사업연도 끝 · 설계속도 · 원본 칸 · plan_route_years · plan_route_assignees · 회사 「산림청」 행 - is_forest_email · is_forest_service · /api/auth/session 에 is_forest_service - require_project_access 한 곳 — 지정 회사 = GET · HEAD 만 · 그 밖 403 - POST /api/d01/project(산림청만 · PLAN · 설계속도 기본 = resolve_design_speed) · GET /api/dashboard/plan-routes/assigned - GET /api/e01/source-candidates · POST /api/e01/project(B06 완료만 · STABILITY · 원본 칸 · 자료 복사는 528-4) - 종류별 단계 시드(PLAN 3 · STABILITY 4) · storage 허용 D0x · E0x · B01 목록에 project_kind - 가입 인증 — 메일 *.go.kr = 회사 산림청 · ACTIVE · 화면 회사 칸 「산림청」 잠금 표시 - 지킴 시험 test_528_project_groups.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,16 @@ async def get_optional_session(request: Request) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_forest_email(email: str | None) -> bool:
|
||||
"""메일이 *.go.kr(대소문자 무시) — 가입 때 회사 「산림청」 고정."""
|
||||
return bool(email) and email.strip().lower().endswith(".go.kr")
|
||||
|
||||
|
||||
def is_forest_service(session: dict[str, Any]) -> bool:
|
||||
"""산림청 = 시스템관리자 또는 메일 *.go.kr — 예정노선 생성 · 고침 권한."""
|
||||
return session.get("role") == "SYSTEM_ADMIN" or is_forest_email(session.get("email"))
|
||||
|
||||
|
||||
async def require_master(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]:
|
||||
if not session["is_master"] and session["role"] != "SYSTEM_ADMIN":
|
||||
raise HTTPException(status_code=403, detail="마스터 권한이 필요합니다.")
|
||||
@@ -196,6 +206,19 @@ async def require_project_access(
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row and row[0] != session["company_id"]:
|
||||
# 예정노선을 지정받은 회사 = 보기만(요구 345 · GET 만 통과).
|
||||
await cursor.execute(
|
||||
"SELECT 1 FROM plan_route_assignees"
|
||||
" WHERE project_id = %s AND company_id = %s LIMIT 1",
|
||||
(str(project_id), session["company_id"]),
|
||||
)
|
||||
shared = await cursor.fetchone()
|
||||
if shared:
|
||||
if request.method in ("GET", "HEAD"):
|
||||
return session
|
||||
raise HTTPException(status_code=403, detail="보기만 허용된 예정노선입니다.")
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if row[0] != session["company_id"]:
|
||||
|
||||
@@ -148,15 +148,32 @@ async def consume_otp(otp_id: int) -> None:
|
||||
await connection.commit()
|
||||
|
||||
|
||||
async def complete_registration(user_id: int, is_master: bool) -> None:
|
||||
async def complete_registration(user_id: int, is_master: bool, forest: bool = False) -> str:
|
||||
"""메일 인증 끝 — 계정 상태를 돌려줌. forest(*.go.kr) = 회사 「산림청」 고정 · ACTIVE."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(user_id,),
|
||||
)
|
||||
company = None
|
||||
if forest:
|
||||
await cursor.execute(
|
||||
"SELECT id FROM companies WHERE name = '산림청' AND deleted_at IS NULL"
|
||||
)
|
||||
company = await cursor.fetchone()
|
||||
if company:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET status = 'ACTIVE', company_id = %s, role = 'USER',
|
||||
last_email_verified_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(company[0], user_id),
|
||||
)
|
||||
else:
|
||||
await cursor.execute(
|
||||
"""UPDATE users SET status = 'NO_COMPANY',
|
||||
last_email_verified_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(user_id,),
|
||||
)
|
||||
await connection.commit()
|
||||
return "ACTIVE" if company else "NO_COMPANY"
|
||||
|
||||
|
||||
async def company_master_email(company_id: int) -> str | None:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""종류별 프로젝트 행 만들기 — D 예정노선 · E 안정성평가(요구 345).
|
||||
|
||||
임도설계(ROAD)는 B02 그대로. 저장소 경로 · 초기 폴더는 B02 함수를 그대로 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import (
|
||||
_build_project_storage,
|
||||
_initialize_project_storage,
|
||||
)
|
||||
from common_util.common_util_audit import record_audit
|
||||
from common_util.common_util_storage import get_project_stage_path
|
||||
from common_util.common_util_workflow_state import initialize_project_stages
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
KIND_STAGE_DIRS = {
|
||||
"PLAN": ("D02_PlanBase", "D03_PlanAnalysis", "D04_PlanConfirm"),
|
||||
"STABILITY": ("E02_StabGround", "E03_StabSection", "E04_Stab3D", "E05_StabReport"),
|
||||
}
|
||||
|
||||
|
||||
async def create_kind_project(
|
||||
*,
|
||||
request: Any | None,
|
||||
user_id: int,
|
||||
company_id: int,
|
||||
kind: str,
|
||||
name: str,
|
||||
region: str | None,
|
||||
road_type: str | None,
|
||||
project_year: int | None,
|
||||
project_year_end: int | None = None,
|
||||
design_speed_kph: int | None = None,
|
||||
route_start_m: float | None = None,
|
||||
route_end_m: float | None = None,
|
||||
source_project_id: str | None = None,
|
||||
) -> str:
|
||||
"""projects 행 + 참여자 + 종류별 단계 + 저장소 폴더 → 새 project_id."""
|
||||
project_id = str(uuid4())
|
||||
storage_path, project_root = _build_project_storage(company_id, user_id, project_id)
|
||||
now = datetime.utcnow()
|
||||
|
||||
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
|
||||
try:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO projects (
|
||||
id, user_id, company_id, name, region, road_type, project_kind,
|
||||
project_year, project_year_end, design_speed_kph,
|
||||
route_start_m, route_end_m, status, crs_epsg, storage_path,
|
||||
source_project_id, source_copied_at, created_at, updated_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s,
|
||||
%s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
user_id,
|
||||
company_id,
|
||||
name,
|
||||
region,
|
||||
road_type,
|
||||
kind,
|
||||
project_year,
|
||||
project_year_end,
|
||||
design_speed_kph,
|
||||
route_start_m,
|
||||
route_end_m,
|
||||
storage_path,
|
||||
source_project_id,
|
||||
now if source_project_id else None,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
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, kind)
|
||||
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)
|
||||
for stage_dir in KIND_STAGE_DIRS[kind]:
|
||||
get_project_stage_path(str(project_root), stage_dir)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return project_id
|
||||
@@ -20,9 +20,19 @@ STAGE_KEYS = [
|
||||
]
|
||||
|
||||
|
||||
async def initialize_project_stages(cursor: aiomysql.DictCursor, project_id: str) -> None:
|
||||
"""프로젝트 생성 시 7개의 단계를 NOT_STARTED 상태로 시드한다."""
|
||||
for stage_no, stage_key in enumerate(STAGE_KEYS):
|
||||
# 종류별 단계(요구 345) — ROAD = B03~B09 · PLAN = D02~D04 · STABILITY = E02~E05.
|
||||
STAGE_KEYS_BY_KIND = {
|
||||
"ROAD": STAGE_KEYS,
|
||||
"PLAN": ["PLAN_BASE", "PLAN_ANALYSIS", "PLAN_CONFIRM"],
|
||||
"STABILITY": ["STAB_GROUND", "STAB_SECTION", "STAB_3D", "STAB_REPORT"],
|
||||
}
|
||||
|
||||
|
||||
async def initialize_project_stages(
|
||||
cursor: aiomysql.DictCursor, project_id: str, kind: str = "ROAD"
|
||||
) -> None:
|
||||
"""프로젝트 생성 시 종류별 단계를 NOT_STARTED 상태로 시드한다."""
|
||||
for stage_no, stage_key in enumerate(STAGE_KEYS_BY_KIND[kind]):
|
||||
await cursor.execute(
|
||||
"""
|
||||
INSERT INTO project_workflow_stages (
|
||||
|
||||
Reference in New Issue
Block a user