Files
Aislo/B03_FileInput/B03_FileInput_Schema.py
T
eomsangdonandClaude Opus 5 1eb5ad4a60 feat(B03): 입력 화면을 계획노선/지형 두 컨테이너로 나누고 파일마다 카드를 준다
노선 파일이 다섯이면 카드도 다섯이어야 어느 것이 왔는지 보인다(사용자 지시).
세트를 슬롯 하나에 몰아 담던 companions 구조를 걷어내고, 슬롯 하나가 파일 하나를
갖는 기존 구조로 되돌렸다 - 업로드 루프도 원래대로다.

- 왼쪽 컨테이너 계획노선 자료: csv(.csv/.shp) shx dbf cpg route_prj
- 오른쪽 컨테이너 지형 자료(LAS): las_laz prj(지형 좌표계) tfw tif + LAS 없는 설계 토글
- .prj만 확장자로 안 갈린다 - 노선 도형과 basename이 같으면 노선 좌표계 카드,
  아니면 지형 카드(planSlotAssignments). 재접속 현황은 저장 경로(input/shp/)로 가른다.
- 재접속 현황 응답에 relative_path 추가.
- 필수 판정이 노선 PRJ를 route_prj로 따로 센다 - 지형 PRJ 없이 통과하던 구멍을 막았다.
- 형제 카드(shx/dbf/route_prj)는 노선 도형이 shapefile일 때만 필수 - 확장자 줄의
  "선택" 꼬리표가 실시간으로 붙고 떨어진다.
- 카드가 좁아져 한글 제목이 글자 단위로 접히던 것을 word-break: keep-all과 헤더
  flex-wrap으로 고쳤다.

화면 검증(공용 브라우저): 실물 7파일을 한 번에 떨어뜨려 배정 실측 - route.prj는
노선 좌표계 카드, terrain.prj는 지형 카드로 갈렸고 카드 제목 9개 모두 한 줄.
tmp/tests/test_route_shapefile_input.py 9개 통과, tsc --noEmit 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:50:39 +09:00

161 lines
5.5 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)
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)
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
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 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