노선 파일이 다섯이면 카드도 다섯이어야 어느 것이 왔는지 보인다(사용자 지시). 세트를 슬롯 하나에 몰아 담던 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>
161 lines
5.5 KiB
Python
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
|