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

View File

@@ -1,9 +1,9 @@
"""
rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin.
Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione,
fatture, ULA, soccorso istruttorio.
fatture, ULA, soccorso istruttorio, upload file, verbale istruttoria.
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic).
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic) + weasyprint.
Auth: JWT condiviso con GEPAFIN-BE.
"""
import logging
@@ -14,7 +14,8 @@ from sqlalchemy import text
from .config import get_settings
from .db import engine, Base
from .routers import health, schemas, practices, debug, instructor
from .migrations import run_migrations
from .routers import health, schemas, practices, debug, instructor, files, verbale
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api")
@@ -25,12 +26,12 @@ settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Avvio rendicontazione-api")
# Crea schema e tabelle se non esistono (bootstrap sandbox)
try:
with engine.begin() as conn:
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}'))
Base.metadata.create_all(bind=engine)
log.info(f"Schema '{settings.db_schema}' e tabelle inizializzate")
run_migrations(engine)
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
except Exception as e:
log.error(f"Errore bootstrap DB: {e}")
raise
@@ -41,7 +42,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="rendicontazione-api",
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
version="0.1.0",
version="0.3.0",
lifespan=lifespan,
)
@@ -58,13 +59,15 @@ app.include_router(schemas.router)
app.include_router(practices.router)
app.include_router(debug.router)
app.include_router(instructor.router)
app.include_router(files.router)
app.include_router(verbale.router)
@app.get("/", tags=["root"])
def root():
return {
"service": "rendicontazione-api",
"version": "0.1.0",
"version": "0.3.0",
"docs": "/docs",
"health": "/health",
}

56
app/migrations.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Migration idempotente per sandbox.
Base.metadata.create_all() crea solo tabelle mancanti, non colonne aggiuntive.
Qui gestiamo ALTER TABLE ADD COLUMN IF NOT EXISTS per l'evoluzione dello schema.
Ogni migration è una stringa SQL che puo essere eseguita piu volte senza errori.
"""
from sqlalchemy import text
import logging
log = logging.getLogger("rendicontazione-api.migrations")
MIGRATIONS = [
# 2026-04-18: colonne file upload su remission_invoice
"""
ALTER TABLE gepafin_rendic.remission_invoice
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer,
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
""",
# 2026-04-18: colonne file upload su remission_ula_employee
"""
ALTER TABLE gepafin_rendic.remission_ula_employee
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer,
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
""",
# 2026-04-18: colonne file upload su remission_document
"""
ALTER TABLE gepafin_rendic.remission_document
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
ADD COLUMN IF NOT EXISTS mime varchar(128),
ADD COLUMN IF NOT EXISTS size_bytes bigint,
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
ADD COLUMN IF NOT EXISTS uploaded_by integer;
""",
]
def run_migrations(engine) -> None:
"""Esegue tutte le migration in transazione. Log su ciascuna."""
with engine.begin() as conn:
for i, sql in enumerate(MIGRATIONS, 1):
try:
conn.execute(text(sql))
log.info(f"migration {i}/{len(MIGRATIONS)} OK")
except Exception as e:
log.error(f"migration {i} FAILED: {e}")
raise

View File

@@ -4,7 +4,7 @@ Schema: gepafin_rendic (stesso DB del BE Gepafin sandbox).
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date, BigInteger
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
@@ -102,7 +102,15 @@ class RemissionInvoice(Base):
taxable = Column(Numeric(14, 2), nullable=False) # imponibile
vat = Column(Numeric(14, 2), nullable=False, default=0)
total = Column(Numeric(14, 2), nullable=False)
pdf_filename = Column(String(512), nullable=True) # per ora solo nome, upload vero dopo
pdf_filename = Column(String(512), nullable=True) # nome originale
# File upload (bind mount /var/uploads dentro container)
storage_path = Column(String(1024), nullable=True) # relativo a /var/uploads
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
uploaded_at = Column(DateTime(timezone=True), nullable=True)
# Campi istruttoria (dual declared/verified)
taxable_verified = Column(Numeric(14, 2), nullable=True)
@@ -139,7 +147,15 @@ class RemissionUlaEmployee(Base):
period_end_date = Column(Date, nullable=False)
supporting_doc_type = Column(String(64), nullable=True)
supporting_doc_filename = Column(String(512), nullable=True)
supporting_doc_filename = Column(String(512), nullable=True) # nome originale
# File upload
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
uploaded_at = Column(DateTime(timezone=True), nullable=True)
# Campi istruttoria
fte_pct_verified = Column(Numeric(5, 4), nullable=True)
@@ -164,11 +180,18 @@ class RemissionDocument(Base):
nullable=False)
doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ...
filename = Column(String(512), nullable=True)
filename = Column(String(512), nullable=True) # nome originale
uploaded_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(Date, nullable=True)
notes = Column(Text, nullable=True)
# File upload
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
# Campi istruttoria
verification_status = Column(String(16), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO | SCADUTO

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

159
app/storage.py Normal file
View File

@@ -0,0 +1,159 @@
"""
Storage adapter per file upload.
Implementazione attuale: filesystem locale con bind mount /var/uploads.
Struttura: /var/uploads/{application_id}/{entity_type}/{entity_id}/{sha256}-{filename}
Migrazione futura a S3/MinIO: cambiare solo questa classe.
"""
import hashlib
import os
import shutil
from pathlib import Path
from typing import BinaryIO, Optional, Tuple
from uuid import UUID
BASE_PATH = Path(os.environ.get("RENDIC_UPLOAD_BASE", "/var/uploads"))
MAX_SIZE_BYTES = 15 * 1024 * 1024 # 15 MB
ALLOWED_MIMES = {
"application/pdf": ".pdf",
"image/jpeg": ".jpg",
"image/png": ".png",
}
class StorageError(Exception):
pass
class FileTooLargeError(StorageError):
pass
class MimeNotAllowedError(StorageError):
pass
def _safe_filename(name: str, max_len: int = 120) -> str:
"""Rimuove caratteri pericolosi, tronca."""
keep = "-_.() "
clean = "".join(c if (c.isalnum() or c in keep) else "_" for c in name)
clean = clean.strip().replace(" ", "_")
if len(clean) > max_len:
root, ext = os.path.splitext(clean)
clean = root[: max_len - len(ext)] + ext
return clean or "file"
def save_upload(
application_id: int,
entity_type: str, # invoice | ula | document
entity_id: UUID,
file_obj: BinaryIO,
original_filename: str,
content_type: Optional[str],
) -> Tuple[str, int, str, str, str]:
"""
Salva il file su FS e ritorna (storage_path, size_bytes, sha256, mime, safe_filename).
storage_path è RELATIVO a BASE_PATH (es: "1/invoice/xxx/yyy-fattura.pdf").
Valida:
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
- dimensione <= MAX_SIZE_BYTES
"""
if entity_type not in ("invoice", "ula", "document"):
raise StorageError(f"entity_type non valido: {entity_type}")
safe_name = _safe_filename(original_filename)
ext = os.path.splitext(safe_name)[1].lower()
# Risolvi mime: prima content_type client, poi da estensione
mime = (content_type or "").lower().split(";")[0].strip()
if mime not in ALLOWED_MIMES:
# Fallback da estensione
ext_to_mime = {v: k for k, v in ALLOWED_MIMES.items()}
mime = ext_to_mime.get(ext, "")
if mime not in ALLOWED_MIMES:
raise MimeNotAllowedError(
f"MIME non consentito: '{content_type}' / estensione '{ext}'. "
f"Accettati: {list(ALLOWED_MIMES.keys())}"
)
# Calcola sha256 e size streaming per non tenere tutto in RAM
hasher = hashlib.sha256()
size = 0
# Salva in tmp poi rename atomico
target_dir = BASE_PATH / str(application_id) / entity_type / str(entity_id)
target_dir.mkdir(parents=True, exist_ok=True)
tmp_path = target_dir / f".tmp-{entity_id}"
try:
with open(tmp_path, "wb") as out:
while True:
chunk = file_obj.read(65536)
if not chunk:
break
size += len(chunk)
if size > MAX_SIZE_BYTES:
raise FileTooLargeError(
f"File {size} byte oltre limite {MAX_SIZE_BYTES}"
)
hasher.update(chunk)
out.write(chunk)
digest = hasher.hexdigest()
final_name = f"{digest[:12]}-{safe_name}"
final_path = target_dir / final_name
# Se già esiste (dedup per sha+nome) rimuovi tmp e usa esistente
if final_path.exists():
tmp_path.unlink(missing_ok=True)
else:
os.replace(tmp_path, final_path)
rel_path = str(final_path.relative_to(BASE_PATH))
return rel_path, size, digest, mime, safe_name
except Exception:
# cleanup tmp in caso di errore
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
raise
def delete_file(storage_path: str) -> bool:
"""Elimina fisicamente il file. Ritorna True se rimosso."""
if not storage_path:
return False
abs_path = BASE_PATH / storage_path
# hardening: resta sotto BASE_PATH
try:
abs_path.resolve().relative_to(BASE_PATH.resolve())
except ValueError:
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
if abs_path.exists():
abs_path.unlink()
# prova a rimuovere directory vuote (entity_id/entity_type)
try:
abs_path.parent.rmdir()
abs_path.parent.parent.rmdir()
except OSError:
pass
return True
return False
def open_file(storage_path: str) -> Path:
"""Ritorna Path assoluto del file. Solleva FileNotFoundError se mancante."""
if not storage_path:
raise FileNotFoundError("storage_path vuoto")
abs_path = BASE_PATH / storage_path
try:
abs_path.resolve().relative_to(BASE_PATH.resolve())
except ValueError:
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
if not abs_path.is_file():
raise FileNotFoundError(f"File non trovato: {storage_path}")
return abs_path