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