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

@@ -18,6 +18,10 @@ class Settings(BaseSettings):
# CORS
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
# Shared secret per endpoint /internal chiamati dal BE Gepafin
# In PROD va cambiato via env var RENDIC_INTERNAL_SECRET
internal_secret: str = "sandbox-internal-secret-ChangeMeInProd-AtLeast32Chars"
class Config:
env_file = ".env"
env_prefix = "RENDIC_"

View File

@@ -15,7 +15,7 @@ from sqlalchemy import text
from .config import get_settings
from .db import engine, Base
from .migrations import run_migrations
from .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment
from .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment, internal
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api")
@@ -63,6 +63,7 @@ app.include_router(files.router)
app.include_router(verbale.router)
app.include_router(custom_checks.router)
app.include_router(assignment.router)
app.include_router(internal.router)
@app.get("/", tags=["root"])

View File

@@ -106,6 +106,59 @@ MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_custom_check_practice
ON gepafin_rendic.remission_custom_check_value(practice_id);
""",
# 2026-04-20 v3: soccorso istruttorio speculare al BE Gepafin
# - stato DRAFT (istruttore prepara, non ancora inviato)
# - response_days + extended_days + extension_date (prolunghe)
# - internal_note (visibile solo istruttore, separata da request_text)
# - amendment_document_* (allegato istruttore al soccorso, firmato e no)
# - response_document_* (upload risposta beneficiario)
# - protocol_id + email_log_id + user_action_id (popolati dal BE via mark-pec-sent)
# - pec_sent_at + pec_failed_reason + pec_retry_after (tracking PEC asincrono)
# Lato microservizio NON gestiamo PEC ne protocollo: il BE multi-tenant
# (gepafin_schema.hub id=1 PEC_SERVICE, id=2 MAILGUN_SERVICE) fa polling
# su endpoint /internal/remission-amendments e notifica via mark-pec-sent/failed.
"""
ALTER TABLE gepafin_rendic.remission_amendment_request
ADD COLUMN IF NOT EXISTS response_days integer,
ADD COLUMN IF NOT EXISTS extended_days integer,
ADD COLUMN IF NOT EXISTS extension_date timestamptz,
ADD COLUMN IF NOT EXISTS internal_note text,
ADD COLUMN IF NOT EXISTS amendment_document_path varchar(1024),
ADD COLUMN IF NOT EXISTS amendment_document_type varchar(128),
ADD COLUMN IF NOT EXISTS amendment_initial_document_path varchar(1024),
ADD COLUMN IF NOT EXISTS response_document_path varchar(1024),
ADD COLUMN IF NOT EXISTS response_document_type varchar(128),
ADD COLUMN IF NOT EXISTS protocol_id varchar(128),
ADD COLUMN IF NOT EXISTS email_log_id integer,
ADD COLUMN IF NOT EXISTS user_action_id integer,
ADD COLUMN IF NOT EXISTS pec_sent_at timestamptz,
ADD COLUMN IF NOT EXISTS pec_failed_reason text,
ADD COLUMN IF NOT EXISTS pec_retry_after timestamptz;
CREATE INDEX IF NOT EXISTS idx_amendment_status_pec
ON gepafin_rendic.remission_amendment_request(status)
WHERE status IN ('DRAFT','AWAITING');
CREATE INDEX IF NOT EXISTS idx_amendment_deadline
ON gepafin_rendic.remission_amendment_request(deadline)
WHERE status = 'AWAITING';
""",
# 2026-04-20 v4: tabella config reminder data-driven, speculare al BE
# (expiration_config type='AMENDMENT' interval_days=N). Permette righe multiple
# per triggerare reminder a N gg diversi dalla scadenza (es. 7gg + 2gg).
"""
CREATE TABLE IF NOT EXISTS gepafin_rendic.remission_expiration_config (
id serial PRIMARY KEY,
type varchar(50) NOT NULL,
interval_days integer NOT NULL CHECK (interval_days > 0),
is_deleted boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_expiration_config_type
ON gepafin_rendic.remission_expiration_config(type)
WHERE is_deleted = false;
INSERT INTO gepafin_rendic.remission_expiration_config (type, interval_days)
VALUES ('AMENDMENT', 7), ('AMENDMENT', 2)
ON CONFLICT DO NOTHING;
""",
]

View File

@@ -235,12 +235,47 @@ class RemissionAmendmentRequest(Base):
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by = Column(Integer, nullable=True)
# soccorso v3: extended/document/PEC tracking
response_days = Column(Integer, nullable=True)
extended_days = Column(Integer, nullable=True)
extension_date = Column(DateTime(timezone=True), nullable=True)
internal_note = Column(Text, nullable=True)
amendment_document_path = Column(String(1024), nullable=True)
amendment_document_type = Column(String(128), nullable=True)
amendment_initial_document_path = Column(String(1024), nullable=True)
response_document_path = Column(String(1024), nullable=True)
response_document_type = Column(String(128), nullable=True)
# popolati dal BE via endpoint interni mark-pec-sent / mark-pec-failed
protocol_id = Column(String(128), nullable=True)
email_log_id = Column(Integer, nullable=True)
user_action_id = Column(Integer, nullable=True)
pec_sent_at = Column(DateTime(timezone=True), nullable=True)
pec_failed_reason = Column(Text, nullable=True)
pec_retry_after = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
practice = relationship("RemissionPractice", back_populates="amendment_requests")
class RemissionExpirationConfig(Base):
"""Config data-driven per reminder scadenze amendment (speculare a BE Gepafin
expiration_config). Ogni riga con type='AMENDMENT' e interval_days=N triggera
un reminder esattamente N giorni prima della scadenza. Multipli row = multipli reminder."""
__tablename__ = "remission_expiration_config"
__table_args__ = ({"schema": "gepafin_rendic"},)
id = Column(Integer, primary_key=True, autoincrement=True)
type = Column(String(50), nullable=False)
interval_days = Column(Integer, nullable=False)
is_deleted = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
class RemissionCustomCheckValue(Base):
"""Valore di un controllo custom configurato dallo schema del bando.
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.

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]})

View File

@@ -5,6 +5,7 @@ from typing import Optional, Any, List
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID
from enum import Enum
from pydantic import BaseModel, Field
@@ -211,10 +212,36 @@ class GateCheckResult(BaseModel):
# ====================== Istruttoria ======================
# Stati formali amendment (speculare BE Gepafin ApplicationAmendmentRequestEnum, ridotti)
class AmendmentStatus(str, Enum):
DRAFT = "DRAFT" # istruttore prepara, PEC non ancora partita
AWAITING = "AWAITING" # PEC inviata, attendo risposta benef
RESPONSE_RECEIVED = "RESPONSE_RECEIVED" # benef ha risposto, istruttore deve valutare
EXPIRED = "EXPIRED" # deadline passata senza risposta (scheduler)
CLOSED = "CLOSED" # istruttore chiude dopo response o comunque
class AmendmentRequestCreate(BaseModel):
request_text: str
deadline: date
scope: Optional[dict] = None
response_days: Optional[int] = None # pre-compilato default 15gg, variabile per istruttore
internal_note: Optional[str] = None # visibile solo istruttore
class AmendmentRequestUpdate(BaseModel):
"""Modifica amendment solo in stato DRAFT."""
request_text: Optional[str] = None
deadline: Optional[date] = None
scope: Optional[dict] = None
response_days: Optional[int] = None
internal_note: Optional[str] = None
class AmendmentExtend(BaseModel):
"""Prolunga la deadline di un amendment AWAITING."""
extended_days: int = Field(..., gt=0, le=60)
motivation: Optional[str] = None
class AmendmentResponseSubmit(BaseModel):
@@ -235,9 +262,63 @@ class AmendmentRequestOut(BaseModel):
closed_by: Optional[int] = None
created_at: datetime
# soccorso v3
response_days: Optional[int] = None
extended_days: Optional[int] = None
extension_date: Optional[datetime] = None
internal_note: Optional[str] = None
amendment_document_path: Optional[str] = None
amendment_document_type: Optional[str] = None
response_document_path: Optional[str] = None
response_document_type: Optional[str] = None
protocol_id: Optional[str] = None
pec_sent_at: Optional[datetime] = None
pec_failed_reason: Optional[str] = None
model_config = {"from_attributes": True}
# ===== schemas per endpoint interni chiamati dal BE Gepafin =====
class AmendmentPendingPecOut(BaseModel):
"""Lista amendment che il BE deve processare per invio PEC."""
id: UUID
practice_id: UUID
application_id: int
request_text: str
deadline: date
response_days: Optional[int] = None
amendment_document_path: Optional[str] = None
created_at: datetime
class AmendmentPecDetail(BaseModel):
"""Dettaglio richiesto dal BE per comporre PEC."""
id: UUID
practice_id: UUID
application_id: int
company_id: int
call_id: int
sequence_number: int
period_label: Optional[str] = None
request_text: str
deadline: date
response_days: Optional[int] = None
amendment_document_path: Optional[str] = None
class MarkPecSent(BaseModel):
protocol_id: str
email_log_id: Optional[int] = None
user_action_id: Optional[int] = None
pec_sent_at: Optional[datetime] = None
class MarkPecFailed(BaseModel):
reason: str
retry_after: Optional[datetime] = None
class ReviewRejectBody(BaseModel):
rejection_reason: str