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).
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""Endpoint /internal/* chiamati dal BE Gepafin (polling + callback).
|
|
|
|
Auth: header X-Internal-Secret con valore settings.internal_secret.
|
|
Non passa per JWT utente — e comunicazione M2M tra servizi.
|
|
|
|
Flusso:
|
|
1. BE poller chiama GET /internal/remission-amendments?status=pending-pec
|
|
2. Per ogni item chiama GET /internal/remission-amendments/{id} per dettagli
|
|
3. BE compone PEC (template per-hub), chiama PEC Massiva / Mailgun
|
|
4. BE callback POST /internal/remission-amendments/{id}/mark-pec-sent (o failed)
|
|
|
|
Il microservizio resta tenant-agnostic: non conosce hub_id, non tocca PEC.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
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
|
|
from ..schemas import (
|
|
ApiResponse, AmendmentPendingPecOut, AmendmentPecDetail,
|
|
MarkPecSent, MarkPecFailed, AmendmentStatus
|
|
)
|
|
|
|
router = APIRouter(prefix="/internal/remission-amendments", tags=["internal"])
|
|
|
|
|
|
def _check_internal_auth(
|
|
x_internal_secret: Optional[str] = Header(None, alias="X-Internal-Secret"),
|
|
settings: Settings = Depends(get_settings),
|
|
):
|
|
"""Valida shared secret. In PROD aggiungere anche IP allowlist via middleware."""
|
|
if not x_internal_secret or x_internal_secret != settings.internal_secret:
|
|
raise HTTPException(status_code=401, detail="Invalid internal secret")
|
|
return True
|
|
|
|
|
|
def _fetch_application_id(db: Session, practice_id: UUID) -> int:
|
|
"""Recupera application_id dalla pratica. Il BE lo userà per risolvere hub/tenant."""
|
|
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
|
return p.application_id
|
|
|
|
|
|
@router.get("", response_model=ApiResponse)
|
|
def list_pending_pec(
|
|
status_filter: str = Query("pending-pec", alias="status",
|
|
description="pending-pec (nuove AWAITING senza PEC), pending-reminder (retry richiesto)"),
|
|
since: Optional[datetime] = Query(None, description="ISO datetime, filtra updated_at >= since"),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
db: Session = Depends(get_db),
|
|
_: bool = Depends(_check_internal_auth),
|
|
):
|
|
"""Lista amendment da processare (polling BE). Due filtri:
|
|
- pending-pec: status=AWAITING AND pec_sent_at IS NULL (prime invio)
|
|
- pending-reminder: status=AWAITING AND pec_sent_at IS NOT NULL AND pec_retry_after IS NOT NULL
|
|
"""
|
|
q = db.query(RemissionAmendmentRequest)
|
|
|
|
if status_filter == "pending-pec":
|
|
q = q.filter(
|
|
RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value,
|
|
RemissionAmendmentRequest.pec_sent_at.is_(None),
|
|
)
|
|
elif status_filter == "pending-reminder":
|
|
q = q.filter(
|
|
RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value,
|
|
RemissionAmendmentRequest.pec_sent_at.isnot(None),
|
|
RemissionAmendmentRequest.pec_retry_after.isnot(None),
|
|
)
|
|
else:
|
|
raise HTTPException(status_code=422, detail="status deve essere pending-pec o pending-reminder")
|
|
|
|
if since is not None:
|
|
q = q.filter(RemissionAmendmentRequest.updated_at >= since)
|
|
|
|
q = q.order_by(RemissionAmendmentRequest.created_at.asc()).limit(limit)
|
|
results = q.all()
|
|
|
|
items = []
|
|
for ar in results:
|
|
application_id = _fetch_application_id(db, ar.practice_id)
|
|
items.append(AmendmentPendingPecOut(
|
|
id=ar.id, practice_id=ar.practice_id, application_id=application_id,
|
|
request_text=ar.request_text, deadline=ar.deadline,
|
|
response_days=ar.response_days,
|
|
amendment_document_path=ar.amendment_document_path,
|
|
created_at=ar.created_at,
|
|
).model_dump(mode="json"))
|
|
return ApiResponse(message=f"{len(items)} amendment pending",
|
|
data={"items": items, "count": len(items)})
|
|
|
|
|
|
@router.get("/{amendment_id}", response_model=ApiResponse)
|
|
def get_amendment_detail(
|
|
amendment_id: UUID,
|
|
db: Session = Depends(get_db),
|
|
_: bool = Depends(_check_internal_auth),
|
|
):
|
|
"""Dettaglio completo per comporre PEC lato BE. Include application_id, company_id,
|
|
call_id, sequence_number (per il titolo 'II fase 2021', ecc.)."""
|
|
ar = db.query(RemissionAmendmentRequest).filter(
|
|
RemissionAmendmentRequest.id == amendment_id
|
|
).first()
|
|
if not ar:
|
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
|
|
|
p = db.query(RemissionPractice).filter(RemissionPractice.id == ar.practice_id).first()
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="Pratica collegata non trovata")
|
|
|
|
# serve company_id + call_id: il BE li dovrebbe gia sapere da application_id,
|
|
# ma glieli restituiamo pure qui per evitare join extra lato loro.
|
|
# Non avendo accesso a application/call nel microservizio (sono su gepafin_schema),
|
|
# facciamo una SELECT diretta.
|
|
row = db.execute(text("""
|
|
SELECT a.company_id, a.call_id
|
|
FROM gepafin_schema.application a
|
|
WHERE a.id = :app_id
|
|
"""), {"app_id": p.application_id}).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail=f"Application {p.application_id} non trovata")
|
|
company_id, call_id = row
|
|
|
|
detail = AmendmentPecDetail(
|
|
id=ar.id, practice_id=p.id, application_id=p.application_id,
|
|
company_id=company_id, call_id=call_id,
|
|
sequence_number=p.sequence_number, period_label=p.period_label,
|
|
request_text=ar.request_text, deadline=ar.deadline,
|
|
response_days=ar.response_days,
|
|
amendment_document_path=ar.amendment_document_path,
|
|
)
|
|
return ApiResponse(message="ok", data=detail.model_dump(mode="json"))
|
|
|
|
|
|
@router.post("/{amendment_id}/mark-pec-sent", response_model=ApiResponse)
|
|
def mark_pec_sent(
|
|
amendment_id: UUID, body: MarkPecSent,
|
|
db: Session = Depends(get_db),
|
|
_: bool = Depends(_check_internal_auth),
|
|
):
|
|
"""Callback dal BE: PEC inviata con successo. Salva protocol_id + email_log_id + ts."""
|
|
ar = db.query(RemissionAmendmentRequest).filter(
|
|
RemissionAmendmentRequest.id == amendment_id
|
|
).first()
|
|
if not ar:
|
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
|
if ar.status != AmendmentStatus.AWAITING.value:
|
|
raise HTTPException(status_code=409,
|
|
detail=f"mark-pec-sent atteso solo su AWAITING (attuale: {ar.status})")
|
|
|
|
ar.protocol_id = body.protocol_id
|
|
ar.email_log_id = body.email_log_id
|
|
ar.user_action_id = body.user_action_id
|
|
ar.pec_sent_at = body.pec_sent_at or datetime.now(timezone.utc)
|
|
ar.pec_failed_reason = None
|
|
ar.pec_retry_after = None # reset retry flag (era usato come "send reminder")
|
|
db.commit()
|
|
return ApiResponse(message="PEC marcata come inviata",
|
|
data={"id": str(amendment_id), "protocol_id": body.protocol_id,
|
|
"pec_sent_at": ar.pec_sent_at.isoformat()})
|
|
|
|
|
|
@router.post("/{amendment_id}/mark-pec-failed", response_model=ApiResponse)
|
|
def mark_pec_failed(
|
|
amendment_id: UUID, body: MarkPecFailed,
|
|
db: Session = Depends(get_db),
|
|
_: bool = Depends(_check_internal_auth),
|
|
):
|
|
"""Callback dal BE: PEC fallita. Salva motivazione + eventuale retry_after."""
|
|
ar = db.query(RemissionAmendmentRequest).filter(
|
|
RemissionAmendmentRequest.id == amendment_id
|
|
).first()
|
|
if not ar:
|
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
|
|
|
ar.pec_failed_reason = body.reason[:2000] # limite safety
|
|
ar.pec_retry_after = body.retry_after
|
|
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}"'},
|
|
)
|