feat(internal): download endpoint per PDF allegati — istruttore + response benef

Risposta a richiesta Rinaldo (team BE bflows-bandi-be) per integrazione S3
folder pratica unico. Il BE scaricherà i PDF binary via nostri endpoint
e li archivierà su S3 nel folder {practice_id}/amendments/{id}/.

==ENDPOINT NUOVI (2, simmetrici)==
GET /internal/remission-amendments/{id}/document
    → PDF istruttore (amendment_document_path) binary stream

GET /internal/remission-amendments/{id}/response-document
    → PDF benef risposta (response_document_path) binary stream

Auth: X-Internal-Secret (riusa _check_internal_auth come gli altri /internal).
Risposta: application/pdf con Content-Disposition attachment + filename originale
(estratto dal pattern {sha256}-{nome} del path fisico).

==IMPLEMENTAZIONE==
- Nuovo helper _resolve_amendment_file(amendment_id, db, kind) che:
    1. Carica l'amendment (404 se non esiste)
    2. Seleziona il path in base al kind ('instructor' | 'response')
    3. 404 se il campo è NULL (es. benef non ha ancora risposto)
    4. Hardening path traversal: abs_path.resolve().relative_to(BASE_PATH)
    5. 404 se file non presente su filesystem
    6. Estrae safe_name dal pattern {sha}-{nome.ext}
- FileResponse streaming, media_type da amendment.*_document_type
- Import BASE_PATH + StorageError da ..storage

==TEST (8 step, /tmp/td2.py, tutti verdi)==
1. crea amendment DRAFT
2. upload PDF istruttore  → HTTP 200, 526 bytes
3. GET /document          → 200, byte-exact, ct=application/pdf, filename preservato
4. GET senza X-Internal-Secret → 401
5. GET amendment inesistente → 404
6. GET /response-document prima che benef allegi → 404
7. benef upload response_document
8. GET /response-document → 200, byte-exact, filename preservato

==RISPOSTA RINALDO==
- amendment_document_path NON è S3 → path FS relativo a /var/uploads
- 1 solo file per ruolo (istruttore + benef): due campi distinti in DB
- Download via questi 2 endpoint simmetrici con shared secret
- Pull-on-upload dal poller BE: dopo mark-pec-sent scarica PDF e lo archivia
  S3 folder pratica; simmetrico per response_document quando il benef risponde

==NOTA MIGRAZIONE FUTURA==
app/storage.py aveva già nota 'Migrazione futura a S3/MinIO: cambiare solo
questa classe'. Questi endpoint download restano validi anche dopo migrazione
S3 lato microservizio (cambia solo impl interna di _resolve_amendment_file).

Breakdown effort a Rinaldo si aggiorna: opzione A = volume condiviso, o
opzione B = questi endpoint (ora implementati, disponibili subito).
This commit is contained in:
BFLOWS
2026-04-24 15:38:21 +02:00
parent 34c4a47a1c
commit 1dbf542104

View File

@@ -16,9 +16,12 @@ from uuid import UUID
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, text
from ..storage import BASE_PATH, StorageError
from ..db import get_db
from ..config import get_settings, Settings
from ..models import RemissionAmendmentRequest, RemissionPractice
@@ -185,3 +188,80 @@ def mark_pec_failed(
db.commit()
return ApiResponse(message="PEC marcata come fallita",
data={"id": str(amendment_id), "reason": body.reason[:200]})
def _resolve_amendment_file(amendment_id: UUID, db: Session,
kind: str) -> tuple:
"""Risolve il path fisico di un allegato amendment.
kind: 'instructor' (amendment_document_path) | 'response' (response_document_path).
Ritorna tuple (abs_path, mime, safe_filename_hint).
Solleva HTTPException(404) se amendment non esiste o non ha il file.
Hardening: path deve restare dentro BASE_PATH (no traversal).
"""
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
if kind == "instructor":
rel_path, mime = ar.amendment_document_path, ar.amendment_document_type
elif kind == "response":
rel_path, mime = ar.response_document_path, ar.response_document_type
else:
raise HTTPException(status_code=422, detail=f"kind invalido: {kind}")
if not rel_path:
raise HTTPException(status_code=404,
detail=f"Nessun documento {kind} per amendment {amendment_id}")
abs_path = BASE_PATH / rel_path
try:
abs_path.resolve().relative_to(BASE_PATH.resolve())
except ValueError:
raise HTTPException(status_code=500, detail="Path non valido (hardening)")
if not abs_path.is_file():
raise HTTPException(status_code=404,
detail=f"File non presente su storage: {rel_path}")
# filename hint = tail dopo l'ultimo "-" (struttura {sha}-{nome.ext})
base = abs_path.name
if "-" in base:
safe_name = base.split("-", 1)[1]
else:
safe_name = base
return abs_path, (mime or "application/pdf"), safe_name
@router.get("/{amendment_id}/document")
def get_amendment_document(
amendment_id: UUID,
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Scarica il PDF allegato dall'istruttore al soccorso (binary stream).
Usato dal poller BE per archiviare su S3 nel folder pratica.
"""
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "instructor")
return FileResponse(
path=str(abs_path), media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)
@router.get("/{amendment_id}/response-document")
def get_response_document(
amendment_id: UUID,
db: Session = Depends(get_db),
_: bool = Depends(_check_internal_auth),
):
"""Scarica il PDF allegato dal beneficiario come risposta (binary stream).
Usato dal poller BE per archiviare su S3 nel folder pratica.
"""
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "response")
return FileResponse(
path=str(abs_path), media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)