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:
269
app/routers/files.py
Normal file
269
app/routers/files.py
Normal 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")
|
||||
Reference in New Issue
Block a user