""" Endpoint file upload/download/delete per fatture, ULA, documenti. Storage: FS locale via app.storage. Autorizzazione: - UPLOAD: beneficiario su sua pratica in DRAFT/AWAITING_AMENDMENT, o istruttore - DOWNLOAD: beneficiario su sua pratica, istruttore, superadmin - DELETE: solo beneficiario su DRAFT/AWAITING_AMENDMENT """ from datetime import datetime, timezone from uuid import UUID from typing import Literal from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi.responses import FileResponse, Response from sqlalchemy.orm import Session from ..db import get_db from ..auth import AuthUser, get_current_user from ..models import ( RemissionPractice, RemissionInvoice, RemissionUlaEmployee, RemissionDocument ) from ..storage import ( save_upload, delete_file, open_file, FileTooLargeError, MimeNotAllowedError, StorageError ) from ..schemas import ApiResponse router = APIRouter(prefix="/api/remission-files", tags=["files"]) def _is_instructor(user: AuthUser) -> bool: return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN") def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool: """Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore.""" if _is_instructor(user): return True if user.is_beneficiary() and practice.user_id == user.user_id: return practice.status in ("DRAFT", "AWAITING_AMENDMENT") return False def _can_download(user: AuthUser, practice: RemissionPractice) -> bool: if _is_instructor(user): return True if user.is_beneficiary() and practice.user_id == user.user_id: return True return False def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool: """Solo beneficiario su pratica modificabile. Istruttore non elimina file.""" if user.is_beneficiary() and practice.user_id == user.user_id: return practice.status in ("DRAFT", "AWAITING_AMENDMENT") if user.is_superadmin(): return True return False def _load_entity(db: Session, entity_type: str, entity_id: UUID): if entity_type == "invoice": return db.query(RemissionInvoice).filter(RemissionInvoice.id == entity_id).first() if entity_type == "ula": return db.query(RemissionUlaEmployee).filter(RemissionUlaEmployee.id == entity_id).first() if entity_type == "document": return db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first() raise HTTPException(status_code=400, detail=f"entity_type non valido: {entity_type}") def _serialize_file_meta(entity, entity_type: str) -> dict: """Estrae metadata file dall'entita (invoice/ula/document).""" filename_field = { "invoice": "pdf_filename", "ula": "supporting_doc_filename", "document": "filename", }[entity_type] has_file = bool(getattr(entity, "storage_path", None)) return { "has_file": has_file, "filename_original": getattr(entity, filename_field, None), "storage_path": entity.storage_path if has_file else None, "mime": entity.mime, "size_bytes": entity.size_bytes, "sha256": entity.sha256, "uploaded_at": entity.uploaded_at.isoformat() if getattr(entity, "uploaded_at", None) else None, } @router.post("/{entity_type}/{entity_id}/upload", response_model=ApiResponse) async def upload_entity_file( entity_type: Literal["invoice", "ula", "document"], entity_id: UUID, file: UploadFile = File(...), db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user), ): """ Upload file per una entita (invoice/ula/document). Se l'entita ha gia un file, lo sostituisce (vecchio file eliminato da FS). """ entity = _load_entity(db, entity_type, entity_id) if not entity: raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata") # Pratica di riferimento practice = db.query(RemissionPractice).filter( RemissionPractice.id == entity.practice_id ).first() if not practice: raise HTTPException(status_code=404, detail="Pratica non trovata") if not _can_upload(user, practice): raise HTTPException( status_code=403, detail="Non autorizzato a caricare file su questa pratica" ) # Salva su FS try: rel_path, size, digest, mime, safe_name = save_upload( application_id=practice.application_id, entity_type=entity_type, entity_id=entity.id, file_obj=file.file, original_filename=file.filename or "file.bin", content_type=file.content_type, ) except FileTooLargeError as e: raise HTTPException(status_code=413, detail=str(e)) except MimeNotAllowedError as e: raise HTTPException(status_code=415, detail=str(e)) except StorageError as e: raise HTTPException(status_code=500, detail=f"Errore storage: {e}") # Rimuovi eventuale file precedente su FS se diverso old_path = entity.storage_path if old_path and old_path != rel_path: try: delete_file(old_path) except Exception: pass # non blocchiamo l'upload per cleanup fallito # Aggiorna metadata entity.storage_path = rel_path entity.mime = mime entity.size_bytes = size entity.sha256 = digest entity.uploaded_by = user.user_id if hasattr(entity, "uploaded_at"): entity.uploaded_at = datetime.now(timezone.utc) # Aggiorna filename originale a seconda del tipo if entity_type == "invoice": entity.pdf_filename = safe_name elif entity_type == "ula": entity.supporting_doc_filename = safe_name elif entity_type == "document": entity.filename = safe_name # document ha gia uploaded_at proprio campo entity.uploaded_at = datetime.now(timezone.utc) db.commit() db.refresh(entity) return ApiResponse( success=True, data=_serialize_file_meta(entity, entity_type), message="File caricato", ) @router.get("/{entity_type}/{entity_id}") def download_entity_file( entity_type: Literal["invoice", "ula", "document"], entity_id: UUID, request: Request, inline: int = 0, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user), ): """ Download (inline=0) o preview (inline=1) del file. Ritorna il file con header appropriati. """ entity = _load_entity(db, entity_type, entity_id) if not entity or not entity.storage_path: raise HTTPException(status_code=404, detail="File non presente") practice = db.query(RemissionPractice).filter( RemissionPractice.id == entity.practice_id ).first() if not practice: raise HTTPException(status_code=404, detail="Pratica non trovata") if not _can_download(user, practice): raise HTTPException(status_code=403, detail="Non autorizzato") try: abs_path = open_file(entity.storage_path) except FileNotFoundError: raise HTTPException(status_code=410, detail="File non piu disponibile su storage") filename = None if entity_type == "invoice": filename = entity.pdf_filename elif entity_type == "ula": filename = entity.supporting_doc_filename elif entity_type == "document": filename = entity.filename disposition = "inline" if inline else "attachment" headers = { "Content-Disposition": f'{disposition}; filename="{filename or "file"}"', } return FileResponse( path=str(abs_path), media_type=entity.mime or "application/octet-stream", headers=headers, ) @router.delete("/{entity_type}/{entity_id}", response_model=ApiResponse) def delete_entity_file( entity_type: Literal["invoice", "ula", "document"], entity_id: UUID, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user), ): """Elimina il file allegato. Entita resta, metadata file azzerati.""" entity = _load_entity(db, entity_type, entity_id) if not entity: raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata") practice = db.query(RemissionPractice).filter( RemissionPractice.id == entity.practice_id ).first() if not practice: raise HTTPException(status_code=404, detail="Pratica non trovata") if not _can_delete(user, practice): raise HTTPException(status_code=403, detail="Non autorizzato") if not entity.storage_path: return ApiResponse(success=True, message="Nessun file da eliminare") try: delete_file(entity.storage_path) except Exception: pass # rimuoviamo comunque i metadati entity.storage_path = None entity.mime = None entity.size_bytes = None entity.sha256 = None entity.uploaded_by = None if hasattr(entity, "uploaded_at"): entity.uploaded_at = None if entity_type == "invoice": entity.pdf_filename = None elif entity_type == "ula": entity.supporting_doc_filename = None elif entity_type == "document": entity.filename = None db.commit() return ApiResponse(success=True, message="File eliminato")