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:
@@ -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}"'},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user