feat(files): upload/preview/delete allegati su fatture, ULA, documenti

- models: colonne file inline (storage_path, mime, size_bytes, sha256, uploaded_by, uploaded_at)
  su remission_invoice, remission_ula_employee, remission_document
- migrations: ALTER idempotente al lifespan per evolvere schema in sandbox
- storage: FS adapter /var/uploads con validazione MIME/size, dedup sha256, sanitize
- routers/files: POST upload / GET download (con ?inline=1) / DELETE
  matrix autorizzazioni: beneficiary su DRAFT|AWAITING_AMENDMENT, istruttore read-only, superadmin full
- main: include router files, version bump 0.2.0

Testato E2E con admin JWT: upload 549B PDF -> DB coerente, storage 1/invoice/<uuid>/<sha12>-file.pdf,
download con magic bytes PDF corretti, delete chirurgico con cleanup FS e metadata.
This commit is contained in:
BFLOWS
2026-04-18 16:54:24 +02:00
parent 7fd56175ef
commit 9a0a401ffa
5 changed files with 521 additions and 11 deletions

269
app/routers/files.py Normal file
View File

@@ -0,0 +1,269 @@
"""
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")