From da13ca7478b61631d46f896e6229003bad865830 Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Mon, 20 Apr 2026 22:22:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(amendment):=20soccorso=20istruttorio=20v3?= =?UTF-8?q?=20=E2=80=94=20base=20dati=20+=20endpoint=20CRUD=20+=20internal?= =?UTF-8?q?=20BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/config.py | 4 + app/main.py | 3 +- app/migrations.py | 53 +++++++++++ app/models.py | 35 +++++++ app/routers/instructor.py | 173 +++++++++++++++++++++++++++++------ app/routers/internal.py | 187 ++++++++++++++++++++++++++++++++++++++ app/schemas.py | 81 +++++++++++++++++ 7 files changed, 509 insertions(+), 27 deletions(-) create mode 100644 app/routers/internal.py diff --git a/app/config.py b/app/config.py index ff0c74d..69d85c2 100644 --- a/app/config.py +++ b/app/config.py @@ -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_" diff --git a/app/main.py b/app/main.py index a0a11d5..710eec1 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/migrations.py b/app/migrations.py index dd50778..b9c8257 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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; + """, ] diff --git a/app/models.py b/app/models.py index b8ee3b9..fdba11b 100644 --- a/app/models.py +++ b/app/models.py @@ -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. diff --git a/app/routers/instructor.py b/app/routers/instructor.py index 8f25e21..703de5f 100644 --- a/app/routers/instructor.py +++ b/app/routers/instructor.py @@ -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") diff --git a/app/routers/internal.py b/app/routers/internal.py new file mode 100644 index 0000000..baf5757 --- /dev/null +++ b/app/routers/internal.py @@ -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]}) diff --git a/app/schemas.py b/app/schemas.py index b21d001..51bc8bc 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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