""" 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 pydantic import BaseModel from fastapi.responses import FileResponse, Response from sqlalchemy import text 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_owner_role() 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_owner_role() 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_owner_role() 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") # ---------- Link da repository company ---------- # 2026-04-20: riutilizzo documenti caricati in fase domanda. # Il benef seleziona un documento dal proprio repository company invece di caricarlo # dal PC. Non c'e upload fisico: copiamo solo i metadati (filename, expires_at, # storage_path per preview/download) e tracciamo source_company_document_id per # permettere lookup live dello status sorgente (VALID/DUE/EXPIRED). class LinkFromRepositoryRequest(BaseModel): company_document_id: int @router.post("/document/{entity_id}/link-from-repository", response_model=ApiResponse) def link_document_from_repository( entity_id: UUID, body: LinkFromRepositoryRequest, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user), ): """ Associa un remission_document esistente a un company_document del repository della fase domanda. Sostituisce eventuali file precedenti caricati dal PC (elimina dallo storage, azzera storage_path). """ # 1) carica il remission_document e verifica permesso upload (benef owner o admin) entity = db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first() if not entity: raise HTTPException(status_code=404, detail="Documento non trovato") 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") # pratica editabile solo in DRAFT (stessa regola dell'upload) if practice.status != "DRAFT": raise HTTPException(status_code=409, detail=f"Pratica in stato {practice.status}: non modificabile") # 2) leggi il company_document (deve esistere, stessa company della pratica, non eliminato) row = db.execute(text(""" SELECT id, file_name, file_path, type, status, expiration_date, company_id FROM gepafin_schema.company_document WHERE id = :cid AND is_deleted = false """), {"cid": body.company_document_id}).mappings().first() if not row: raise HTTPException(status_code=404, detail=f"company_document {body.company_document_id} non trovato") if row["company_id"] != practice.company_id: raise HTTPException( status_code=403, detail="Documento repository non appartiene alla company di questa pratica" ) # 3) se c'era un file fisico caricato dal PC in precedenza, lo rimuoviamo per pulizia if entity.storage_path and not entity.source_company_document_id: try: delete_file(entity.storage_path) except Exception: pass # non bloccare se il file non c'e piu # 4) aggiorna metadati con quelli del repository from datetime import datetime, timezone, date entity.source_company_document_id = row["id"] entity.filename = row["file_name"] entity.storage_path = row["file_path"] # riuso del path fisico del BE per preview/download entity.mime = None entity.size_bytes = None entity.sha256 = None entity.uploaded_by = user.user_id entity.uploaded_at = datetime.now(timezone.utc) # scadenza dal sorgente (timestamp -> date) exp = row["expiration_date"] if exp is not None: entity.expires_at = exp.date() if hasattr(exp, 'date') else exp else: entity.expires_at = None db.commit() db.refresh(entity) return ApiResponse( success=True, message=f"Documento collegato dal repository (source_status={row['status']})", data={ "id": str(entity.id), "doc_code": entity.doc_code, "filename": entity.filename, "source_company_document_id": entity.source_company_document_id, "expires_at": entity.expires_at.isoformat() if entity.expires_at else None, "source_status": row["status"], # VALID | DUE | EXPIRED — per UI semaforo } )