feat(B01): 임시 보관함 UI를 그룹+파일 리스트 컨테이너로 개편
프로젝트/사용자 관리 컨테이너와 같은 형태로 통일한다. 등록 폼을 화면에 상시
노출하지 않고, 우측 상단 [+] 모달로 받아 그룹(임시 프로젝트명) 아래 파일 표를
그린다.
프론트엔드
- B01_Dashboard_UI_TempModal.ts 신설: 등록/추가 모달(임시 프로젝트명 + 파일 선택,
고른 파일을 모달 안 표로 표시, 하단 [취소][확인] — 기존 대시보드 모달과 동일 규격)
- B01_Dashboard_UI_TempUpload.ts: 섹션 전체(제목·[+]·목록)를 반환하도록 변경.
그룹 카드 + 파일 표(종류/파일명/크기/상태/작업), 그룹별 [파일 추가],
파일별 [삭제], 진행률은 해당 파일 행 안에서 표시
- 청크마다 목록을 다시 조회하지 않고 막대 DOM만 갱신하도록 정리
- 대시보드 배치: 프로젝트 컨테이너 바로 아래(역할 3분기 모두)
백엔드
- DELETE /api/temp-uploads/{batch_id}/files/{file_type}: DB 행과 임시 저장소
실제 파일을 함께 삭제. 필수 파일이 빠지면 상태를 uploading으로 되돌리고
만료일은 최초 완료 시점 기준을 유지
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,63 @@ async def upsert_temp_batch_file(
|
||||
)
|
||||
|
||||
|
||||
async def get_temp_batch_file(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
batch_id: str,
|
||||
file_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""묶음 안 파일 1건(종류로 지정). 삭제 전 실제 저장 경로를 알아내는 용도."""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT batch_id, file_type, original_filename, relative_path, file_size_bytes
|
||||
FROM temp_upload_files
|
||||
WHERE batch_id = %s AND file_type = %s
|
||||
""",
|
||||
(batch_id, file_type),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise LookupError("보관함 파일을 찾을 수 없습니다.")
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def delete_temp_batch_file(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
batch_id: str,
|
||||
file_type: str,
|
||||
) -> None:
|
||||
"""묶음 안 파일 1건 삭제(행만 — 실제 파일은 라우터가 지운다)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"DELETE FROM temp_upload_files WHERE batch_id = %s AND file_type = %s",
|
||||
(batch_id, file_type),
|
||||
)
|
||||
|
||||
|
||||
async def mark_temp_batch_incomplete(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
batch_id: str,
|
||||
) -> None:
|
||||
"""필수 파일이 빠지면 다시 '업로드 중'으로 되돌린다.
|
||||
|
||||
만료일(`expires_at`)은 손대지 않는다 — 보관 기한은 **처음 완료 시점** 기준이고,
|
||||
파일을 지웠다 다시 올리는 것으로 기한이 늘어나면 안 된다.
|
||||
"""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE temp_upload_batches
|
||||
SET status = 'uploading'
|
||||
WHERE id = %s AND status = 'completed'
|
||||
""",
|
||||
(batch_id,),
|
||||
)
|
||||
|
||||
|
||||
async def get_temp_batch_file_types(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
|
||||
@@ -36,7 +36,9 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
|
||||
create_temp_batch,
|
||||
create_temp_upload_session,
|
||||
delete_temp_batch,
|
||||
delete_temp_batch_file,
|
||||
get_temp_batch,
|
||||
get_temp_batch_file,
|
||||
get_temp_batch_file_types,
|
||||
get_temp_upload_session,
|
||||
is_batch_required_complete,
|
||||
@@ -44,6 +46,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import (
|
||||
list_temp_batch_sessions,
|
||||
list_temp_batches,
|
||||
mark_temp_batch_completed,
|
||||
mark_temp_batch_incomplete,
|
||||
mark_temp_batch_linked,
|
||||
upsert_temp_batch_file,
|
||||
)
|
||||
@@ -102,11 +105,14 @@ async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path:
|
||||
|
||||
|
||||
async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool:
|
||||
"""필수 파일이 다 찼으면 완료로 올린다. 완료 여부를 돌려준다."""
|
||||
"""필수 파일 충족 여부에 맞춰 상태를 맞춘다. 완료 여부를 돌려준다."""
|
||||
file_types = await get_temp_batch_file_types(connection, batch_id=batch_id)
|
||||
complete = is_batch_required_complete(file_types)
|
||||
if complete:
|
||||
await mark_temp_batch_completed(connection, batch_id=batch_id)
|
||||
else:
|
||||
# 파일을 지워 필수 조건이 깨진 경우 — 프로젝트 연결 대상에서 빠져야 한다.
|
||||
await mark_temp_batch_incomplete(connection, batch_id=batch_id)
|
||||
return complete
|
||||
|
||||
|
||||
@@ -235,6 +241,55 @@ async def remove_batch(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{batch_id}/files/{file_type}")
|
||||
async def remove_batch_file(
|
||||
batch_id: str,
|
||||
file_type: str,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> JSONResponse:
|
||||
"""묶음 안 파일 1건을 지운다 — DB 행과 임시 저장소 실제 파일을 함께 지운다."""
|
||||
user_id = int(session["user_id"])
|
||||
normalized = file_type.lower().lstrip(".")
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
batch = await get_temp_batch(connection, batch_id=batch_id, user_id=user_id)
|
||||
if str(batch["status"]) == "linked":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "프로젝트에 연결된 보관함은 수정할 수 없습니다.",
|
||||
},
|
||||
)
|
||||
item = await get_temp_batch_file(connection, batch_id=batch_id, file_type=normalized)
|
||||
await delete_temp_batch_file(connection, batch_id=batch_id, file_type=normalized)
|
||||
required_complete = await _refresh_batch_status(connection, batch_id=batch_id)
|
||||
await connection.commit()
|
||||
|
||||
batch_root = Path(resolve_temp_batch_path(user_id, batch_id, create=False))
|
||||
stored = batch_root / str(item["relative_path"])
|
||||
stored.unlink(missing_ok=True)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"batch_id": batch_id,
|
||||
"file_type": normalized,
|
||||
"required_complete": required_complete,
|
||||
}
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except OSError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("보관함 파일 삭제 실패: batch_id=%s type=%s", batch_id, file_type)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "파일 삭제 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{batch_id}/files", response_model=TempFileUploadResponse)
|
||||
async def upload_batch_files(
|
||||
batch_id: str,
|
||||
|
||||
Reference in New Issue
Block a user