"""File upload, listing, and removal for a session.""" from __future__ import annotations import hashlib import os from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sqlalchemy.orm import Session as OrmSession from ...config import ALLOWED_EXTENSIONS, MAX_UPLOAD_BYTES, UPLOAD_DIR from ...core.xlsx_reader import TransactionReader, ParseError from ...db import models from ..deps import db_dep, file_dict, get_session_or_404, sanitize_filename router = APIRouter(prefix="/api/sessions", tags=["files"]) @router.get("/{session_id}/files") def list_files(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]: get_session_or_404(session_id, db) rows = db.query(models.SessionFile).filter(models.SessionFile.session_id == session_id).all() return [file_dict(f) for f in rows] @router.post("/{session_id}/files") async def upload_files(session_id: int, files: list[UploadFile] = File(...), db: OrmSession = Depends(db_dep)) -> list[dict]: session = get_session_or_404(session_id, db) dest_dir = UPLOAD_DIR / f"session_{session_id}" dest_dir.mkdir(parents=True, exist_ok=True) out = [] for uf in files: safe = sanitize_filename(uf.filename or "upload.xlsx") ext = os.path.splitext(safe)[1].lower() if ext not in ALLOWED_EXTENSIONS: raise HTTPException(400, f"Unsupported file type: {safe} ({ext})") path = dest_dir / safe h = hashlib.sha256() size = 0 with open(path, "wb") as fh: while True: chunk = await uf.read(1 << 20) if not chunk: break size += len(chunk) if size > MAX_UPLOAD_BYTES: fh.close() os.remove(path) raise HTTPException(413, f"File too large: {safe}") h.update(chunk) fh.write(chunk) rec = models.SessionFile( session_id=session_id, filename=safe, stored_path=str(path), size_bytes=size, sha256=h.hexdigest(), status="uploaded", ) # light validation: detect sheet + required columns (no full row scan) try: reader = TransactionReader(str(path)) reader.detect() rec.data_sheet = reader.sheet_name rec.status = "invalid" if reader.column_mapping.missing_required else "parsed" if reader.column_mapping.missing_required: rec.message = f"missing required columns: {reader.column_mapping.missing_required}" reader.close() except ParseError as e: rec.status = "invalid" rec.message = str(e) db.add(rec) out.append(rec) session.status = "draft" db.commit() return [file_dict(f) for f in out] @router.delete("/{session_id}/files/{file_id}") def delete_file(session_id: int, file_id: int, db: OrmSession = Depends(db_dep)) -> dict: f = db.get(models.SessionFile, file_id) if not f or f.session_id != session_id: raise HTTPException(404, "File not found") try: if f.stored_path and os.path.exists(f.stored_path): os.remove(f.stored_path) except OSError: pass db.delete(f) db.commit() return {"deleted": file_id}