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 typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query from fastapi import APIRouter, Depends, HTTPException, Header, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, text from sqlalchemy import and_, or_, text
from ..storage import BASE_PATH, StorageError
from ..db import get_db from ..db import get_db
from ..config import get_settings, Settings from ..config import get_settings, Settings
from ..models import RemissionAmendmentRequest, RemissionPractice from ..models import RemissionAmendmentRequest, RemissionPractice
@@ -185,3 +188,80 @@ def mark_pec_failed(
db.commit() db.commit()
return ApiResponse(message="PEC marcata come fallita", return ApiResponse(message="PEC marcata come fallita",
data={"id": str(amendment_id), "reason": body.reason[:200]}) 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}"'},
)