ROUND 1 della replica soccorso istruttorio speculare al BE Gepafin
bflows-bandi-be. Pacchetto base pronto, mancano scheduler/upload/email/FE
che vengono in round successivi.
==ARCHITETTURA DECISA CON CARLO==
- multi-tenancy lato BE: microservizio resta tenant-agnostic
- BE (bflows-bandi-be) fa polling sul nostro /internal e invia PEC/protocollo
tenant-aware (hub=1 Gepafin PEC_SERVICE, hub=2 SviluppUmbria MAILGUN_SERVICE)
- microservizio NON fa PEC ne protocollo, NON conosce hub_id
- endpoint interni autenticati via shared secret X-Internal-Secret
==MIGRATION DB (2)==
mig 7: ALTER TABLE remission_amendment_request ADD
response_days, extended_days, extension_date, internal_note,
amendment_document_path/type, amendment_initial_document_path,
response_document_path/type, protocol_id, email_log_id, user_action_id,
pec_sent_at, pec_failed_reason, pec_retry_after
+ 2 index partial (status pec-pending, deadline scadenti)
mig 8: nuova tabella remission_expiration_config (type, interval_days,
is_deleted) per reminder data-driven speculare a expiration_config BE.
Seeded con (AMENDMENT, 7) e (AMENDMENT, 2).
==MODELLI==
- RemissionAmendmentRequest esteso con 13 colonne nuove
- RemissionExpirationConfig nuovo
==SCHEMAS==
- AmendmentStatus enum (DRAFT, AWAITING, RESPONSE_RECEIVED, EXPIRED, CLOSED)
- AmendmentRequestCreate esteso (response_days, internal_note)
- AmendmentRequestUpdate nuovo (solo DRAFT)
- AmendmentExtend nuovo (proroga)
- AmendmentPendingPecOut, AmendmentPecDetail (per BE polling)
- MarkPecSent, MarkPecFailed (callback BE)
==ENDPOINT ISTRUTTORE (estesi o nuovi)==
- POST /{pid}/amendment crea DRAFT (modifica: non piu AWAITING diretto)
- PUT /{pid}/amendment/{id} modifica solo DRAFT [NUOVO]
- DELETE /{pid}/amendment/{id} elimina solo DRAFT [NUOVO]
- POST /{pid}/amendment/{id}/send DRAFT -> AWAITING [NUOVO]
- POST /{pid}/amendment/{id}/extend proroga deadline [NUOVO]
- POST /{pid}/amendment/{id}/reminder reminder manuale (flag pec_retry_after) [NUOVO]
- POST /{pid}/amendment/{id}/close chiude (AmendmentStatus enum al posto di stringhe)
- POST /{pid}/amendment/{id}/respond-beneficiary benef risponde
==ENDPOINT INTERNI /internal/remission-amendments (nuovi)==
- GET ?status=pending-pec|pending-reminder&since=
- GET /{id} detail per composizione PEC
- POST /{id}/mark-pec-sent callback BE success
- POST /{id}/mark-pec-failed callback BE failure
Auth: X-Internal-Secret header, 401 altrimenti.
==CONFIG==
RENDIC_INTERNAL_SECRET env var (default sandbox hard-coded).
==TEST E2E==
/tmp/test_amendment_v3.py - 10 step tutti verdi:
A reset T2 UNDER_REVIEW
B create DRAFT (response_days=15 default)
C update DRAFT (response_days=20, internal_note)
D send DRAFT->AWAITING, pratica AWAITING_AMENDMENT
E BE poll pending-pec vede amendment
F BE detail+mark-pec-sent salva protocol_id/email_log_id/user_action_id
G dopo mark-pec-sent scompare da pending-pec
H benef respond -> RESPONSE_RECEIVED
I istruttore close -> CLOSED, pratica torna UNDER_REVIEW
AUTH internal senza secret -> 401
==NEXT (non in questo commit)==
- scheduler APScheduler cron 01:00 EXPIRED + cron 09:00 reminder
- upload amendment_document (istruttore) + response_document (benef) via files router
- template email locali non-PEC (reminder istruttore, notifica chiusura)
- UI istruttore: lista amendment + form crea/invia + proroga + reminder manuale
- UI benef: vista amendment + risposta con upload
188 lines
7.8 KiB
Python
188 lines
7.8 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 sqlalchemy.orm import Session
|
|
from sqlalchemy import and_, or_, text
|
|
|
|
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]})
|