Files
Aislo/B03_FileInput/B03_FileInput_Schema.py
eomsangdonandClaude Opus 5 018ebe7f40 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>
2026-09-09 22:43:28 +09:00

183 lines
6.8 KiB
Python

"""B03 파일 입력 요청·응답 검증 모델."""
from pathlib import PurePath
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
from config.config_system import UPLOAD_ALLOWED_EXT, UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
class FileUploadDescriptor(BaseModel):
"""업로드 전에 검증할 단일 파일의 이름과 크기."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
original_filename: str = Field(min_length=1, max_length=255)
size_bytes: int = Field(gt=0)
@model_validator(mode="after")
def validate_file(self) -> "FileUploadDescriptor":
filename = self.original_filename
if PurePath(filename).name != filename or "/" in filename or "\\" in filename:
raise ValueError("파일명에는 경로가 포함될 수 없습니다.")
extension = PurePath(filename).suffix.lower()
if extension not in UPLOAD_ALLOWED_EXT:
allowed = ", ".join(UPLOAD_ALLOWED_EXT)
raise ValueError(f"허용되지 않은 파일 확장자입니다. 허용 형식: {allowed}")
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
if self.size_bytes > maximum_bytes:
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
return self
class UploadedFileResult(BaseModel):
"""저장 완료된 단일 입력 파일 정보."""
input_file_id: int
original_filename: str
file_type: str
relative_path: str
size_bytes: int
metadata: dict[str, Any]
class FileUploadResponse(BaseModel):
"""B03 다중 파일 업로드 성공 응답."""
status: str = "success"
project_id: str
files: list[UploadedFileResult]
class ChunkSessionCreateRequest(FileUploadDescriptor):
"""청크 업로드 세션 생성 요청."""
chunk_size_bytes: int = Field(default=UPLOAD_CHUNK_SIZE_BYTES, gt=0)
complete_upload: bool = False
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
las_free: bool = False
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class ChunkSessionCreateResponse(BaseModel):
"""청크 업로드 세션 생성 응답."""
status: str = "success"
project_id: str
upload_session_id: str
original_filename: str
file_size_bytes: int
chunk_size_bytes: int
total_chunks: int
completed_chunks: int = 0
# 이미 같은 내용이 올라와 있어 전송을 건너뛰어도 되는지. true면 세션을 만들지 않는다.
already_uploaded: bool = False
class ChunkUploadResponse(BaseModel):
"""단일 청크 저장 응답."""
status: str = "success"
upload_session_id: str
chunk_index: int
completed_chunks: int
total_chunks: int
chunk_hash: str
class UploadFinalizeRequest(BaseModel):
"""청크 업로드 완료 및 병합 요청."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
session_id: str = Field(min_length=1, max_length=36)
total_chunks: int = Field(gt=0)
complete_upload: bool = True
# LAS 없는 설계(도엽등고선 기반, 2026-08-30) — 완료 판정에서 LAS 필수를 면제한다.
las_free: bool = False
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class UploadStatusResponse(BaseModel):
"""청크 업로드 상태 조회 응답."""
status: str = "success"
upload_session_id: str
upload_status: str
original_filename: str
file_size_bytes: int
chunk_size_bytes: int
total_chunks: int
completed_chunks: int
completed_chunk_indexes: list[int]
class UploadOverviewFile(BaseModel):
"""재접속 현황 — 업로드 완료된 입력 파일 한 건(서버 정본)."""
input_file_id: int
file_type: str
original_filename: str
file_size_mb: float
status: str
uploaded_at: str | None = None
# PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다
# (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31).
relative_path: str | None = None
# 업로드 분석기가 낸 메타데이터 — 카드 미리보기(범위 사각형·노선 선)를 그리는 재료다
# (2026-09-04). 재접속해도 같은 그림이 서도록 서버 정본을 그대로 실어 보낸다.
metadata: dict[str, Any] | None = None
class UploadOverviewSession(BaseModel):
"""재접속 현황 — 중단된 청크 업로드 세션 한 건(이어올리기 안내용)."""
upload_session_id: str
original_filename: str
file_size_bytes: int
total_chunks: int
completed_chunks: int
progress_percent: float
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):
"""B03 재접속 시 업로드 현황 — localStorage가 아니라 이 응답이 정본이다.
`required_complete`는 필수 파일(LAS/LAZ 1 + csv·prj·tfw)이 모두 업로드된 상태,
`analysis_complete`는 WF1 분석(stage 1)까지 끝난 상태다. 두 값이 모두 참이면
화면은 "완료" 배지를 띄우고, 완료 슬롯에 재업로드하면 교체 경고 모달을 띄운다.
"""
status: str = "success"
files: list[UploadOverviewFile]
pending_sessions: list[UploadOverviewSession]
required_complete: bool
analysis_complete: bool
# 잠금 표시(2026-09-09, 계획서 0-8) — 서버가 새 자료를 막는 동안 화면이 **왜·누구 때문에**
# 막혔는지 보이게 한다. 도는 중이 아니면 `None`.
analysis_lock: UploadLockInfo | None = None