Finance-Accounts/ar-aging-app/backend/app/api/routes/files.py

88 lines
3.3 KiB
Python

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