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:
@@ -18,6 +18,10 @@ class Settings(BaseSettings):
|
|||||||
# CORS
|
# CORS
|
||||||
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_prefix = "RENDIC_"
|
env_prefix = "RENDIC_"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy import text
|
|||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import engine, Base
|
from .db import engine, Base
|
||||||
from .migrations import run_migrations
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
log = logging.getLogger("rendicontazione-api")
|
log = logging.getLogger("rendicontazione-api")
|
||||||
@@ -63,6 +63,7 @@ app.include_router(files.router)
|
|||||||
app.include_router(verbale.router)
|
app.include_router(verbale.router)
|
||||||
app.include_router(custom_checks.router)
|
app.include_router(custom_checks.router)
|
||||||
app.include_router(assignment.router)
|
app.include_router(assignment.router)
|
||||||
|
app.include_router(internal.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["root"])
|
@app.get("/", tags=["root"])
|
||||||
|
|||||||
@@ -106,6 +106,59 @@ MIGRATIONS = [
|
|||||||
CREATE INDEX IF NOT EXISTS idx_custom_check_practice
|
CREATE INDEX IF NOT EXISTS idx_custom_check_practice
|
||||||
ON gepafin_rendic.remission_custom_check_value(practice_id);
|
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;
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -235,12 +235,47 @@ class RemissionAmendmentRequest(Base):
|
|||||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
closed_by = Column(Integer, 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())
|
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())
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
practice = relationship("RemissionPractice", back_populates="amendment_requests")
|
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):
|
class RemissionCustomCheckValue(Base):
|
||||||
"""Valore di un controllo custom configurato dallo schema del bando.
|
"""Valore di un controllo custom configurato dallo schema del bando.
|
||||||
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.
|
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from ..db import get_db
|
|||||||
from ..auth import AuthUser, get_current_user
|
from ..auth import AuthUser, get_current_user
|
||||||
from ..models import RemissionPractice, RemissionAmendmentRequest
|
from ..models import RemissionPractice, RemissionAmendmentRequest
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit,
|
AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
|
||||||
ReviewApproveBody, ReviewRejectBody,
|
ReviewApproveBody, ReviewRejectBody,
|
||||||
InstructorQueueItem, PracticeOut, ApiResponse,
|
InstructorQueueItem, PracticeOut, ApiResponse,
|
||||||
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
|
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
|
||||||
@@ -195,10 +195,29 @@ def reject_practice(practice_id: UUID, body: ReviewRejectBody,
|
|||||||
|
|
||||||
# ========== SOCCORSO ISTRUTTORIO ==========
|
# ========== 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)
|
@router.post("/{practice_id}/amendment", response_model=ApiResponse)
|
||||||
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
||||||
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
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)
|
p = _get_practice_or_404(db, practice_id)
|
||||||
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
|
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
|
||||||
raise HTTPException(status_code=409,
|
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:
|
if not body.request_text or len(body.request_text.strip()) < 10:
|
||||||
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
||||||
|
|
||||||
# controllo: non ci deve essere già una amendment AWAITING aperta
|
# 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 == "AWAITING"]
|
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:
|
if open_ar:
|
||||||
raise HTTPException(status_code=409,
|
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(
|
ar = RemissionAmendmentRequest(
|
||||||
practice_id=p.id,
|
practice_id=p.id,
|
||||||
@@ -218,37 +239,142 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
|||||||
request_text=body.request_text,
|
request_text=body.request_text,
|
||||||
deadline=body.deadline,
|
deadline=body.deadline,
|
||||||
scope=body.scope or {},
|
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)
|
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"
|
p.status = "AWAITING_AMENDMENT"
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ar)
|
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"))
|
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)
|
@router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
|
||||||
def close_amendment(practice_id: UUID, amendment_id: UUID,
|
def close_amendment(practice_id: UUID, amendment_id: UUID,
|
||||||
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
"""Istruttore chiude il soccorso (dopo aver visto la risposta beneficiario).
|
"""Istruttore chiude il soccorso. La pratica torna in UNDER_REVIEW
|
||||||
La pratica torna in UNDER_REVIEW."""
|
se non ci sono altri amendment aperti su di essa."""
|
||||||
ar = db.query(RemissionAmendmentRequest).filter(
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
RemissionAmendmentRequest.id == amendment_id,
|
if ar.status == AmendmentStatus.CLOSED.value:
|
||||||
RemissionAmendmentRequest.practice_id == practice_id
|
raise HTTPException(status_code=409, detail="Amendment gia chiusa")
|
||||||
).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")
|
|
||||||
|
|
||||||
ar.status = "CLOSED"
|
ar.status = AmendmentStatus.CLOSED.value
|
||||||
ar.closed_at = datetime.now(timezone.utc)
|
ar.closed_at = datetime.now(timezone.utc)
|
||||||
ar.closed_by = user.user_id
|
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)
|
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:
|
if not others_open:
|
||||||
p.status = "UNDER_REVIEW"
|
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:
|
if user.is_beneficiary() and p.user_id != user.user_id:
|
||||||
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
||||||
|
|
||||||
ar = db.query(RemissionAmendmentRequest).filter(
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
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 != "AWAITING":
|
if ar.status != "AWAITING":
|
||||||
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")
|
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")
|
||||||
|
|
||||||
|
|||||||
187
app/routers/internal.py
Normal file
187
app/routers/internal.py
Normal 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]})
|
||||||
@@ -5,6 +5,7 @@ from typing import Optional, Any, List
|
|||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -211,10 +212,36 @@ class GateCheckResult(BaseModel):
|
|||||||
|
|
||||||
# ====================== Istruttoria ======================
|
# ====================== 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):
|
class AmendmentRequestCreate(BaseModel):
|
||||||
request_text: str
|
request_text: str
|
||||||
deadline: date
|
deadline: date
|
||||||
scope: Optional[dict] = None
|
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):
|
class AmendmentResponseSubmit(BaseModel):
|
||||||
@@ -235,9 +262,63 @@ class AmendmentRequestOut(BaseModel):
|
|||||||
closed_by: Optional[int] = None
|
closed_by: Optional[int] = None
|
||||||
created_at: datetime
|
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}
|
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):
|
class ReviewRejectBody(BaseModel):
|
||||||
rejection_reason: str
|
rejection_reason: str
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user