feat(B03): 업로드 잠금에 이름을 붙임 — 지금 누가 올리는 중인지 화면에 뜸
계획서 0-8 의 남은 하나. 서버는 분석이 도는 동안 새 자료를 이미 막고 있었고 (`is_analysis_running`), 없던 것은 **막힌 까닭을 사람에게 보이는 것**이었음. 여럿이 한 프로젝트를 보면 「왜 안 올라가지」가 됨. - 분석을 띄우는 **경로 넷 모두** 시작한 사람을 `params.started_by` 로 담음. - `analysis_lock_owner` 가 그 id 로 이름을 붙여 냄 — 옛 자료는 id 가 없어 **이름 없이 잠금만** 돎(막는 것이 먼저). - `/upload-overview` 응답에 `analysis_lock` 을 실어 B03 화면이 띠로 보임. 화면 실측 — 「엄상돈 님이 올린 자료를 분석하는 중입니다」 띠가 경고색으로 뜸(응답을 잠깐 가로채 확인하고 곧바로 되돌림). 지금 도는 분석이 없어 실제 잠금 화면은 다음 업로드 때 볼 것. 시험 넷 추가(도는 중 아님·이름 붙음·옛 자료·경로 넷 대조), 전체 1211 통과·실패 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -211,12 +211,23 @@ export interface UploadOverviewSession {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 지금 분석이 도는 중이면 **누가 시작했는지**. 도는 중이 아니면 `null`. */
|
||||||
|
export interface UploadLockInfo {
|
||||||
|
running: boolean;
|
||||||
|
user_id: number | null;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
started_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UploadOverviewResponse {
|
export interface UploadOverviewResponse {
|
||||||
status: string;
|
status: string;
|
||||||
files: UploadOverviewFile[];
|
files: UploadOverviewFile[];
|
||||||
pending_sessions: UploadOverviewSession[];
|
pending_sessions: UploadOverviewSession[];
|
||||||
required_complete: boolean;
|
required_complete: boolean;
|
||||||
analysis_complete: boolean;
|
analysis_complete: boolean;
|
||||||
|
/** 서버가 새 자료를 막는 동안 **왜·누구 때문에** 막혔는지(2026-09-09, 계획서 0-8). */
|
||||||
|
analysis_lock?: UploadLockInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
|
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ from B03_FileInput.B03_FileInput_Schema import (
|
|||||||
FileUploadDescriptor,
|
FileUploadDescriptor,
|
||||||
FileUploadResponse,
|
FileUploadResponse,
|
||||||
UploadedFileResult,
|
UploadedFileResult,
|
||||||
|
UploadLockInfo,
|
||||||
UploadOverviewFile,
|
UploadOverviewFile,
|
||||||
UploadOverviewResponse,
|
UploadOverviewResponse,
|
||||||
UploadOverviewSession,
|
UploadOverviewSession,
|
||||||
@@ -116,6 +117,7 @@ from common_util.common_util_json import atomic_write_json
|
|||||||
from common_util.common_util_storage import resolve_stored_project_path
|
from common_util.common_util_storage import resolve_stored_project_path
|
||||||
from common_util.common_util_workflow import load_project_workflow
|
from common_util.common_util_workflow import load_project_workflow
|
||||||
from common_util.common_util_workflow_state import (
|
from common_util.common_util_workflow_state import (
|
||||||
|
analysis_lock_owner,
|
||||||
get_workflow_state,
|
get_workflow_state,
|
||||||
is_analysis_running,
|
is_analysis_running,
|
||||||
)
|
)
|
||||||
@@ -283,6 +285,7 @@ async def upload_project_files(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
input_file_id=point_cloud_input_id,
|
input_file_id=point_cloud_input_id,
|
||||||
user_role=str(session["role"]),
|
user_role=str(session["role"]),
|
||||||
|
started_by=session.get("user_id"),
|
||||||
),
|
),
|
||||||
task_name=f"b04-preprocess-auto-{project_id}",
|
task_name=f"b04-preprocess-auto-{project_id}",
|
||||||
)
|
)
|
||||||
@@ -343,6 +346,8 @@ async def get_project_upload_overview(
|
|||||||
)
|
)
|
||||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
state = await get_workflow_state(cursor, str(project_id))
|
state = await get_workflow_state(cursor, str(project_id))
|
||||||
|
# 「누가 올리는 중인지」 — 서버가 막는 것과 화면이 알리는 것이 한 값에서 나온다.
|
||||||
|
lock = await analysis_lock_owner(cursor, str(project_id))
|
||||||
stages = (state or {}).get("stages") or []
|
stages = (state or {}).get("stages") or []
|
||||||
analysis_complete = any(
|
analysis_complete = any(
|
||||||
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
|
int(stage.get("stage_no", -1)) == 1 and str(stage.get("state")) == "COMPLETE"
|
||||||
@@ -386,6 +391,7 @@ async def get_project_upload_overview(
|
|||||||
required_complete=stage0_complete
|
required_complete=stage0_complete
|
||||||
or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None),
|
or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None),
|
||||||
analysis_complete=analysis_complete,
|
analysis_complete=analysis_complete,
|
||||||
|
analysis_lock=UploadLockInfo(**lock) if lock else None,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("B03 업로드 현황 조회 실패: project_id=%s", project_id)
|
logger.exception("B03 업로드 현황 조회 실패: project_id=%s", project_id)
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ async def create_project_upload_session(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
input_file_id=point_cloud_input_id,
|
input_file_id=point_cloud_input_id,
|
||||||
user_role=str(session["role"]),
|
user_role=str(session["role"]),
|
||||||
|
started_by=session.get("user_id"),
|
||||||
),
|
),
|
||||||
task_name=f"b04-preprocess-auto-{project_id}",
|
task_name=f"b04-preprocess-auto-{project_id}",
|
||||||
)
|
)
|
||||||
@@ -333,6 +334,7 @@ async def finalize_project_upload(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
input_file_id=point_cloud_input_id,
|
input_file_id=point_cloud_input_id,
|
||||||
user_role=str(session["role"]),
|
user_role=str(session["role"]),
|
||||||
|
started_by=session.get("user_id"),
|
||||||
),
|
),
|
||||||
task_name=f"b04-preprocess-auto-{project_id}",
|
task_name=f"b04-preprocess-auto-{project_id}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ async def attach_temp_batch(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
input_file_id=point_cloud_input_id,
|
input_file_id=point_cloud_input_id,
|
||||||
user_role=str(session["role"]),
|
user_role=str(session["role"]),
|
||||||
|
started_by=session.get("user_id"),
|
||||||
),
|
),
|
||||||
task_name=f"b04-preprocess-auto-{project_id}",
|
task_name=f"b04-preprocess-auto-{project_id}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -154,6 +154,16 @@ class UploadOverviewSession(BaseModel):
|
|||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadLockInfo(BaseModel):
|
||||||
|
"""지금 분석이 도는 중이면 **누가 시작했는지**. 도는 중이 아니면 응답에 안 실린다."""
|
||||||
|
|
||||||
|
running: bool = True
|
||||||
|
user_id: int | None = None
|
||||||
|
name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UploadOverviewResponse(BaseModel):
|
class UploadOverviewResponse(BaseModel):
|
||||||
"""B03 재접속 시 업로드 현황 — localStorage가 아니라 이 응답이 정본이다.
|
"""B03 재접속 시 업로드 현황 — localStorage가 아니라 이 응답이 정본이다.
|
||||||
|
|
||||||
@@ -167,3 +177,6 @@ class UploadOverviewResponse(BaseModel):
|
|||||||
pending_sessions: list[UploadOverviewSession]
|
pending_sessions: list[UploadOverviewSession]
|
||||||
required_complete: bool
|
required_complete: bool
|
||||||
analysis_complete: bool
|
analysis_complete: bool
|
||||||
|
# 잠금 표시(2026-09-09, 계획서 0-8) — 서버가 새 자료를 막는 동안 화면이 **왜·누구 때문에**
|
||||||
|
# 막혔는지 보이게 한다. 도는 중이 아니면 `None`.
|
||||||
|
analysis_lock: UploadLockInfo | None = None
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ async def trigger_wf1_analysis_and_email(
|
|||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
input_file_id: int,
|
input_file_id: int,
|
||||||
user_role: str,
|
user_role: str,
|
||||||
|
started_by: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""WF1 분석·DB 저장 후 자동 확정하고 이메일을 발송한다.
|
"""WF1 분석·DB 저장 후 자동 확정하고 이메일을 발송한다.
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ async def trigger_wf1_analysis_and_email(
|
|||||||
"source_filters": None,
|
"source_filters": None,
|
||||||
"methods": methods,
|
"methods": methods,
|
||||||
"force": False,
|
"force": False,
|
||||||
|
# 잠금에 **이름을 붙이는 값** — 「누가 올리는 중인지」를 화면이 이것으로 읽는다
|
||||||
|
# (2026-09-09, 계획서 0-8). 없으면 잠금만 돌고 이름은 안 뜬다.
|
||||||
|
"started_by": started_by,
|
||||||
}
|
}
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
async with connection.cursor() as cursor:
|
async with connection.cursor() as cursor:
|
||||||
|
|||||||
@@ -405,6 +405,19 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
|||||||
for (const slot of slots.keys()) renderSlot(slot);
|
for (const slot of slots.keys()) renderSlot(slot);
|
||||||
|
|
||||||
const notes: HTMLElement[] = [];
|
const notes: HTMLElement[] = [];
|
||||||
|
// 잠금 표시 — 서버가 「분석 중이라 못 받는다」로 막는 자리를 **화면이 먼저 알린다**.
|
||||||
|
// 여럿이 한 프로젝트를 볼 때 「왜 안 올라가지」가 아니라 「누가 올리는 중이구나」가
|
||||||
|
// 되게 이름을 함께 적는다(2026-09-09, 계획서 0-8).
|
||||||
|
const lock = overview.analysis_lock;
|
||||||
|
if (lock?.running) {
|
||||||
|
const who = lock.name ?? lock.email ?? null;
|
||||||
|
const busy = document.createElement("p");
|
||||||
|
busy.className = "b03-file__overview-lock";
|
||||||
|
busy.textContent = who
|
||||||
|
? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.`
|
||||||
|
: "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.";
|
||||||
|
notes.push(busy);
|
||||||
|
}
|
||||||
if (overview.required_complete && overview.analysis_complete) {
|
if (overview.required_complete && overview.analysis_complete) {
|
||||||
const complete = document.createElement("p");
|
const complete = document.createElement("p");
|
||||||
complete.className = "b03-file__overview-complete";
|
complete.className = "b03-file__overview-complete";
|
||||||
|
|||||||
@@ -179,6 +179,13 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 잠금 표시 — 지금 누가 올리는 중인지. 막힌 까닭이라 눈에 띄게 둔다. */
|
||||||
|
.b03-file__overview-lock {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-warning, #d98324);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* 완료 슬롯 재업로드 확인 모달 — 페이지 위를 덮는 단순 확인창. */
|
/* 완료 슬롯 재업로드 확인 모달 — 페이지 위를 덮는 단순 확인창. */
|
||||||
.b03-file__modal-backdrop {
|
.b03-file__modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -268,6 +268,57 @@ async def is_analysis_running(cursor: aiomysql.DictCursor, project_id: str) -> b
|
|||||||
return not await _surface_run_settled(cursor, project_id)
|
return not await _surface_run_settled(cursor, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def analysis_lock_owner(
|
||||||
|
cursor: aiomysql.DictCursor, project_id: str
|
||||||
|
) -> Dict[str, Any] | None:
|
||||||
|
"""**지금 누가 올리는 중인지** — 도는 중이 아니면 `None`.
|
||||||
|
|
||||||
|
잠금 자체는 `is_analysis_running` 이 판정한다. 여기서는 그 잠금에 **이름을 붙인다** —
|
||||||
|
한 프로젝트를 여럿이 볼 때 「왜 못 올리지」가 아니라 「누가 올리는 중이구나」가 되게.
|
||||||
|
시작한 사람은 `start_stage` 가 담아 둔 `params.started_by`(사용자 id)에서 읽고,
|
||||||
|
이름은 그때그때 `users` 에서 가져온다(옛 자료는 id 가 없어 이름 없이 잠금만 돈다).
|
||||||
|
"""
|
||||||
|
if not await is_analysis_running(cursor, project_id):
|
||||||
|
return None
|
||||||
|
await cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT params, started_at
|
||||||
|
FROM project_workflow_stages
|
||||||
|
WHERE project_id = %s AND stage_no = 1
|
||||||
|
""",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
raw = row["params"] if isinstance(row, dict) else row[0]
|
||||||
|
started_at = row["started_at"] if isinstance(row, dict) else row[1]
|
||||||
|
params: Dict[str, Any] = {}
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
params = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
params = {}
|
||||||
|
elif isinstance(raw, dict):
|
||||||
|
params = raw
|
||||||
|
user_id = params.get("started_by")
|
||||||
|
name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
if user_id is not None:
|
||||||
|
await cursor.execute("SELECT name, email FROM users WHERE id = %s", (user_id,))
|
||||||
|
user = await cursor.fetchone()
|
||||||
|
if user:
|
||||||
|
name = user["name"] if isinstance(user, dict) else user[0]
|
||||||
|
email = user["email"] if isinstance(user, dict) else user[1]
|
||||||
|
return {
|
||||||
|
"running": True,
|
||||||
|
"user_id": user_id,
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"started_at": started_at.isoformat() if started_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# 계산이 끝나 사용자의 다음 조작을 기다리는 진행 단계 — 도는 중이 아니다.
|
# 계산이 끝나 사용자의 다음 조작을 기다리는 진행 단계 — 도는 중이 아니다.
|
||||||
_SETTLED_SURFACE_STAGES = frozenset({"awaiting_confirmation", "completed", "failed"})
|
_SETTLED_SURFACE_STAGES = frozenset({"awaiting_confirmation", "completed", "failed"})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""업로드 잠금 표시 — **지금 누가 올리는 중인지** (2026-09-09, 계획서 0-8 남은 하나).
|
||||||
|
|
||||||
|
서버는 분석이 도는 동안 새 자료를 이미 막고 있었다(`is_analysis_running`). 없던 것은
|
||||||
|
**막힌 까닭을 화면에 보이는 것**이다 — 여럿이 한 프로젝트를 보면 「왜 안 올라가지」가 된다.
|
||||||
|
|
||||||
|
⇒ 시작한 사람을 `start_stage` 의 `params.started_by` 에 담고, `analysis_lock_owner` 가
|
||||||
|
그 id 로 이름을 붙여 낸다. 옛 자료는 id 가 없어 **이름 없이 잠금만** 돈다(막는 것이 먼저).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from common_util import common_util_workflow_state as state_mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _Cursor:
|
||||||
|
"""질의 한 벌만 흉내 낸다 — 단계 행 하나와 사용자 한 명."""
|
||||||
|
|
||||||
|
def __init__(self, stage: dict[str, Any] | None, user: dict[str, Any] | None) -> None:
|
||||||
|
self.stage = stage
|
||||||
|
self.user = user
|
||||||
|
self._last = ""
|
||||||
|
|
||||||
|
async def execute(self, sql: str, args: tuple[Any, ...] = ()) -> None:
|
||||||
|
self._last = sql
|
||||||
|
|
||||||
|
async def fetchone(self) -> dict[str, Any] | None:
|
||||||
|
return self.user if "FROM users" in self._last else self.stage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _running(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
async def _yes(cursor: Any, project_id: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(state_mod, "is_analysis_running", _yes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_도는_중이_아니면_잠금이_없다(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
async def _no(cursor: Any, project_id: str) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(state_mod, "is_analysis_running", _no)
|
||||||
|
assert asyncio.run(state_mod.analysis_lock_owner(_Cursor(None, None), "p1")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_시작한_사람의_이름이_붙는다(_running) -> None:
|
||||||
|
stage = {"params": json.dumps({"started_by": 7}), "started_at": None}
|
||||||
|
cursor = _Cursor(stage, {"name": "엄상돈", "email": "a@b.c"})
|
||||||
|
lock = asyncio.run(state_mod.analysis_lock_owner(cursor, "p1"))
|
||||||
|
assert lock is not None
|
||||||
|
assert lock["running"] is True and lock["name"] == "엄상돈" and lock["user_id"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_옛_자료는_이름_없이_잠금만_돈다(_running) -> None:
|
||||||
|
"""`started_by` 가 없던 시절 자료 — 막는 것이 먼저다. 이름이 없다고 잠금을 풀지 않는다."""
|
||||||
|
cursor = _Cursor({"params": json.dumps({"force": False}), "started_at": None}, None)
|
||||||
|
lock = asyncio.run(state_mod.analysis_lock_owner(cursor, "p1"))
|
||||||
|
assert lock is not None and lock["running"] is True and lock["name"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_올리는_경로_넷이_모두_시작한_사람을_싣는다() -> None:
|
||||||
|
"""한 경로만 빠져도 그 경로로 올린 잠금은 이름이 안 뜬다 — 옛 사고와 같은 결."""
|
||||||
|
sources = [
|
||||||
|
ROOT / "B03_FileInput" / name
|
||||||
|
for name in (
|
||||||
|
"B03_FileInput_Router.py",
|
||||||
|
"B03_FileInput_Router_Chunks.py",
|
||||||
|
"B03_FileInput_Router_Temp.py",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
calls = sum(
|
||||||
|
source.read_text(encoding="utf-8").count("trigger_wf1_analysis_and_email(")
|
||||||
|
for source in sources
|
||||||
|
)
|
||||||
|
carried = sum(
|
||||||
|
source.read_text(encoding="utf-8").count("started_by=session.get") for source in sources
|
||||||
|
)
|
||||||
|
assert calls == carried, f"분석을 띄우는 자리 {calls} 곳 중 {carried} 곳만 시작한 사람을 싣는다"
|
||||||
Reference in New Issue
Block a user