feat(amendment): soccorso istruttorio v3 — base dati + endpoint CRUD + internal BE

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
This commit is contained in:
BFLOWS
2026-04-20 22:22:37 +02:00
parent 7c8de6aec8
commit da13ca7478
7 changed files with 509 additions and 27 deletions

View File

@@ -14,7 +14,7 @@ from ..db import get_db
from ..auth import AuthUser, get_current_user
from ..models import RemissionPractice, RemissionAmendmentRequest
from ..schemas import (
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit,
AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
ReviewApproveBody, ReviewRejectBody,
InstructorQueueItem, PracticeOut, ApiResponse,
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
@@ -195,10 +195,29 @@ def reject_practice(practice_id: UUID, body: ReviewRejectBody,
# ========== SOCCORSO ISTRUTTORIO ==========
DEFAULT_RESPONSE_DAYS = 15
def _amendment_or_404(db: Session, practice_id: UUID, amendment_id: UUID) -> RemissionAmendmentRequest:
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id,
RemissionAmendmentRequest.practice_id == practice_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
return ar
@router.post("/{practice_id}/amendment", response_model=ApiResponse)
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Crea una richiesta di soccorso istruttorio."""
"""Crea una richiesta di soccorso istruttorio in stato DRAFT.
La PEC parte solo quando l'istruttore chiama esplicitamente /send. Finche e DRAFT:
- l'istruttore puo modificare/eliminare
- la pratica resta UNDER_REVIEW (nessun impatto sul benef)
- nessuna notifica PEC
"""
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409,
@@ -206,11 +225,13 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
if not body.request_text or len(body.request_text.strip()) < 10:
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
# controllo: non ci deve essere già una amendment AWAITING aperta
open_ar = [a for a in p.amendment_requests if a.status == "AWAITING"]
# controllo: non ci deve essere gia una amendment non-CLOSED/non-EXPIRED aperta
open_ar = [a for a in p.amendment_requests
if a.status in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value,
AmendmentStatus.RESPONSE_RECEIVED.value)]
if open_ar:
raise HTTPException(status_code=409,
detail="C'è già una richiesta di soccorso aperta su questa pratica")
detail="C'e gia una richiesta di soccorso aperta su questa pratica")
ar = RemissionAmendmentRequest(
practice_id=p.id,
@@ -218,37 +239,142 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
request_text=body.request_text,
deadline=body.deadline,
scope=body.scope or {},
status="AWAITING"
response_days=body.response_days if body.response_days is not None else DEFAULT_RESPONSE_DAYS,
internal_note=body.internal_note,
status=AmendmentStatus.DRAFT.value
)
db.add(ar)
# pratica resta UNDER_REVIEW in DRAFT (passa a AWAITING_AMENDMENT solo allo /send)
db.commit()
db.refresh(ar)
return ApiResponse(message="Bozza soccorso istruttorio creata",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
@router.put("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse)
def update_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentRequestUpdate,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Modifica una bozza di soccorso. Consentito solo in stato DRAFT.
Dopo invio (AWAITING) il contenuto PEC e immutabile; si puo solo chiudere o prorogare."""
ar = _amendment_or_404(db, practice_id, amendment_id)
if ar.status != AmendmentStatus.DRAFT.value:
raise HTTPException(status_code=409,
detail=f"Modifica consentita solo in stato DRAFT (attuale: {ar.status})")
if body.request_text is not None:
if len(body.request_text.strip()) < 10:
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
ar.request_text = body.request_text
if body.deadline is not None:
ar.deadline = body.deadline
if body.scope is not None:
ar.scope = body.scope
if body.response_days is not None:
if body.response_days < 1 or body.response_days > 120:
raise HTTPException(status_code=422, detail="response_days deve essere 1-120")
ar.response_days = body.response_days
if body.internal_note is not None:
ar.internal_note = body.internal_note
db.commit()
db.refresh(ar)
return ApiResponse(message="Bozza aggiornata",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
@router.delete("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse)
def delete_amendment(practice_id: UUID, amendment_id: UUID,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Elimina una bozza di soccorso. Consentito solo in stato DRAFT.
Una volta inviata (AWAITING) si puo solo chiudere o scadere."""
ar = _amendment_or_404(db, practice_id, amendment_id)
if ar.status != AmendmentStatus.DRAFT.value:
raise HTTPException(status_code=409,
detail=f"Eliminazione consentita solo in stato DRAFT (attuale: {ar.status})")
db.delete(ar)
db.commit()
return ApiResponse(message="Bozza eliminata", data={"id": str(amendment_id)})
@router.post("/{practice_id}/amendment/{amendment_id}/send", response_model=ApiResponse)
def send_amendment(practice_id: UUID, amendment_id: UUID,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Invia il soccorso: DRAFT -> AWAITING.
Da questo momento il BE Gepafin (poller interno) vedra l'amendment come pending-pec
e si occupera di PEC/protocollo tenant-aware. La pratica passa a AWAITING_AMENDMENT
(benef puo modificare) e il benef ricevera notifica quando la PEC arriva davvero."""
ar = _amendment_or_404(db, practice_id, amendment_id)
if ar.status != AmendmentStatus.DRAFT.value:
raise HTTPException(status_code=409,
detail=f"Send consentito solo in stato DRAFT (attuale: {ar.status})")
if not ar.request_text or len(ar.request_text.strip()) < 10:
raise HTTPException(status_code=422, detail="Testo richiesta troppo breve")
ar.status = AmendmentStatus.AWAITING.value
p = _get_practice_or_404(db, practice_id)
p.status = "AWAITING_AMENDMENT"
db.commit()
db.refresh(ar)
return ApiResponse(message="Soccorso istruttorio avviato",
return ApiResponse(message="Soccorso inviato. In attesa di invio PEC da backend.",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
@router.post("/{practice_id}/amendment/{amendment_id}/extend", response_model=ApiResponse)
def extend_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentExtend,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Proroga la deadline di un soccorso AWAITING.
Somma extended_days alla deadline attuale. Traccia extension_date."""
ar = _amendment_or_404(db, practice_id, amendment_id)
if ar.status != AmendmentStatus.AWAITING.value:
raise HTTPException(status_code=409,
detail=f"Proroga consentita solo in stato AWAITING (attuale: {ar.status})")
from datetime import timedelta
ar.deadline = ar.deadline + timedelta(days=body.extended_days)
ar.extended_days = (ar.extended_days or 0) + body.extended_days
ar.extension_date = datetime.now(timezone.utc)
if body.motivation:
ar.internal_note = ((ar.internal_note or "") + f"\n[Proroga {body.extended_days}gg {datetime.now(timezone.utc):%Y-%m-%d}]: {body.motivation}").strip()
db.commit()
db.refresh(ar)
return ApiResponse(message=f"Deadline prorogata di {body.extended_days} giorni",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
@router.post("/{practice_id}/amendment/{amendment_id}/reminder", response_model=ApiResponse)
def send_reminder(practice_id: UUID, amendment_id: UUID,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Reminder manuale on-demand dell'istruttore al benef.
Accoda un secondo invio PEC (stesso contenuto richiesta) via flag interno.
Il BE vedra l'amendment come pending-pec=reminder e inviera email di reminder."""
ar = _amendment_or_404(db, practice_id, amendment_id)
if ar.status != AmendmentStatus.AWAITING.value:
raise HTTPException(status_code=409,
detail="Reminder consentito solo su soccorsi AWAITING")
# flag minimo: segnala via campo separato. Per ora usiamo pec_retry_after come "serve reminder"
# (il BE poller distinguera pec_sent_at IS NULL vs pec_sent_at IS NOT NULL + pec_retry_after)
ar.pec_retry_after = datetime.now(timezone.utc)
db.commit()
return ApiResponse(message="Reminder accodato. Il backend invierà l'email di sollecito.",
data={"amendment_id": str(amendment_id), "queued_at": ar.pec_retry_after.isoformat()})
@router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
def close_amendment(practice_id: UUID, amendment_id: UUID,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Istruttore chiude il soccorso (dopo aver visto la risposta beneficiario).
La pratica torna in UNDER_REVIEW."""
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id,
RemissionAmendmentRequest.practice_id == practice_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
if ar.status == "CLOSED":
raise HTTPException(status_code=409, detail="Amendment già chiusa")
"""Istruttore chiude il soccorso. La pratica torna in UNDER_REVIEW
se non ci sono altri amendment aperti su di essa."""
ar = _amendment_or_404(db, practice_id, amendment_id)
if ar.status == AmendmentStatus.CLOSED.value:
raise HTTPException(status_code=409, detail="Amendment gia chiusa")
ar.status = "CLOSED"
ar.status = AmendmentStatus.CLOSED.value
ar.closed_at = datetime.now(timezone.utc)
ar.closed_by = user.user_id
# rimetto la pratica in UNDER_REVIEW se non ci sono altre amendment aperte
p = _get_practice_or_404(db, practice_id)
others_open = [a for a in p.amendment_requests if a.id != ar.id and a.status == "AWAITING"]
others_open = [a for a in p.amendment_requests
if a.id != ar.id and a.status in (AmendmentStatus.DRAFT.value,
AmendmentStatus.AWAITING.value,
AmendmentStatus.RESPONSE_RECEIVED.value)]
if not others_open:
p.status = "UNDER_REVIEW"
@@ -269,12 +395,7 @@ def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
if user.is_beneficiary() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
ar = db.query(RemissionAmendmentRequest).filter(
RemissionAmendmentRequest.id == amendment_id,
RemissionAmendmentRequest.practice_id == practice_id
).first()
if not ar:
raise HTTPException(status_code=404, detail="Amendment non trovata")
ar = _amendment_or_404(db, practice_id, amendment_id)
if ar.status != "AWAITING":
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")

187
app/routers/internal.py Normal file
View File

@@ -0,0 +1,187 @@
"""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]})