Files
BFLOWS 34c4a47a1c feat(amendment): ROUND 2 — scheduler + upload documenti
Seconda parte della replica soccorso istruttorio speculare al BE Gepafin.
Completata: scheduler cron (expire + reminder), upload documenti istruttore
e benef, fix duplicati config.

==SCHEDULER (app/scheduler.py NUOVO)==
APScheduler BackgroundScheduler integrato nel lifespan FastAPI.
Due cron attivi (timezone Europe/Rome):

  expire_amendments() - cron 01:05 ogni notte
    Speculare a ApplicationAmendmentScheduler.processAmendmentExpirationScheduler.
    Trova amendment AWAITING con deadline < today, passa a EXPIRED.
    Rimette pratica a UNDER_REVIEW se non ha altri amendment aperti.
    Ritorna dict stats per logging/test.

  queue_reminders() - cron 09:00 ogni mattina
    Speculare a ExpirationScheduler.processAmendmentExpiration (data-driven).
    Legge remission_expiration_config (type='AMENDMENT', interval_days=N),
    per ogni riga trova amendment con deadline esattamente today+N e setta
    pec_retry_after (marker che il BE vede via /internal pending-reminder).
    Multipli row = multipli reminder (seed: 7gg + 2gg).

Il microservizio aggiorna solo stato DB. L invio effettivo di email
reminder lo fa il BE Gepafin tramite polling, tenant-aware.

==UPLOAD DOCUMENTI==
3 nuovi endpoint nel router istruttoria:

  POST   /instructor/{pid}/amendment/{aid}/upload-document
    - Istruttore allega PDF al soccorso (motivazione, scheda tecnica).
    - Consentito in DRAFT o AWAITING. Sostituisce precedente se esiste.
    - Popola amendment_document_path + amendment_document_type.

  DELETE /instructor/{pid}/amendment/{aid}/upload-document
    - Rimuove allegato (solo in DRAFT).

  POST   /instructor/{pid}/amendment/{aid}/upload-response-document
    - Benef allega PDF come supporto alla risposta.
    - Consentito in AWAITING/RESPONSE_RECEIVED, solo proprietario.
    - Popola response_document_path + response_document_type.

Riusa save_upload() esistente con entity_type dedicati.

==FIX storage.py==
Whitelist entity_type estesa con 'amendment-instructor-doc' +
'amendment-response-doc' (prima accettava solo invoice/ula/document,
bloccando l'upload con StorageError).

==FIX migration dedup==
Scoperto in test: migration 8 faceva INSERT ON CONFLICT DO NOTHING su
remission_expiration_config ma senza UNIQUE constraint. Ogni restart
inseriva duplicati (16 righe in DB invece di 2). Fix in migration 9:
DELETE duplicati + ADD UNIQUE (type, interval_days) + re-seed pulito.

==REQUIREMENTS==
APScheduler==3.10.4

==TEST E2E==
/tmp/test_amendment_r2_fixed.py passa su tutto:
  [A] upload amendment_document istruttore + response_document benef + respond
  [B] amendment scaduto artificiale -> expire_amendments lo marca EXPIRED,
      pratica torna UNDER_REVIEW
  [C] amendment a +2gg e +7gg -> queue_reminders accoda 2 reminder,
      /internal pending-reminder li espone entrambi
2026-04-20 22:35:01 +02:00

160 lines
5.1 KiB
Python

"""
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 | amendment-instructor-doc | amendment-response-doc
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", "amendment-instructor-doc", "amendment-response-doc"):
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