"""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 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