Compare commits
10 Commits
c19b2aa0b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83bb0a29ec | ||
|
|
1dbf542104 | ||
|
|
34c4a47a1c | ||
|
|
da13ca7478 | ||
|
|
7c8de6aec8 | ||
|
|
a3f863ecdb | ||
|
|
8950633481 | ||
|
|
345856f55c | ||
|
|
aeab399afa | ||
|
|
3021792c31 |
11
app/auth.py
11
app/auth.py
@@ -3,7 +3,7 @@ JWT validation compatibile con GEPAFIN-BE.
|
||||
Il BE Spring emette token HS512 con payload:
|
||||
sub: "email:userId:hubId"
|
||||
userId: int
|
||||
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ...
|
||||
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | "ROLE_CONFIDI" | ...
|
||||
exp: unix timestamp
|
||||
loginAttemptId: int
|
||||
"""
|
||||
@@ -29,6 +29,15 @@ class AuthUser:
|
||||
def is_beneficiary(self) -> bool:
|
||||
return self.role == "ROLE_BENEFICIARY"
|
||||
|
||||
def is_confidi(self) -> bool:
|
||||
return self.role == "ROLE_CONFIDI"
|
||||
|
||||
def is_owner_role(self) -> bool:
|
||||
"""Ruoli che possono essere proprietari di una pratica (user_id match):
|
||||
BENEFICIARY (azienda diretta) o CONFIDI (delegato per conto azienda).
|
||||
Pattern allineato al BE Gepafin (DashboardDao, CompanyDocumentDao)."""
|
||||
return self.role in ("ROLE_BENEFICIARY", "ROLE_CONFIDI")
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
|
||||
@@ -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_"
|
||||
|
||||
@@ -15,7 +15,8 @@ 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 .scheduler import start_scheduler, stop_scheduler
|
||||
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")
|
||||
@@ -32,10 +33,12 @@ async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations(engine)
|
||||
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
|
||||
start_scheduler()
|
||||
except Exception as e:
|
||||
log.error(f"Errore bootstrap DB: {e}")
|
||||
raise
|
||||
yield
|
||||
stop_scheduler()
|
||||
log.info("Shutdown rendicontazione-api")
|
||||
|
||||
|
||||
@@ -63,6 +66,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"])
|
||||
|
||||
@@ -68,6 +68,17 @@ MIGRATIONS = [
|
||||
ON gepafin_rendic.remission_practice(assigned_instructor_id)
|
||||
WHERE assigned_instructor_id IS NULL;
|
||||
""",
|
||||
# 2026-04-20: link documento a company_document del BE Gepafin (riutilizzo dal repository)
|
||||
# Se source_company_document_id e valorizzato, il documento e selezionato dal repository
|
||||
# company (gepafin_schema.company_document). Lo status/scadenza del sorgente governa
|
||||
# semaforo UI e gate submit (documenti EXPIRED bloccano la trasmissione).
|
||||
"""
|
||||
ALTER TABLE gepafin_rendic.remission_document
|
||||
ADD COLUMN IF NOT EXISTS source_company_document_id integer;
|
||||
CREATE INDEX IF NOT EXISTS idx_remission_document_source
|
||||
ON gepafin_rendic.remission_document(source_company_document_id)
|
||||
WHERE source_company_document_id IS NOT NULL;
|
||||
""",
|
||||
# 2026-04-18 v2: tabella custom checks
|
||||
# allineata allo storage adapter esistente (storage_path + mime + size + sha256)
|
||||
# NON segue le specs RAG p1 che usavano document_filename (v1 obsoleta)
|
||||
@@ -95,6 +106,79 @@ 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;
|
||||
""",
|
||||
# 2026-04-20 v5: dedup duplicati (ON CONFLICT DO NOTHING non funzionava senza UNIQUE)
|
||||
# + aggiungo UNIQUE constraint per prevenire futuri duplicati
|
||||
"""
|
||||
DELETE FROM gepafin_rendic.remission_expiration_config ec
|
||||
USING gepafin_rendic.remission_expiration_config ec2
|
||||
WHERE ec.id > ec2.id
|
||||
AND ec.type = ec2.type
|
||||
AND ec.interval_days = ec2.interval_days;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uq_expiration_config_type_days'
|
||||
AND conrelid = 'gepafin_rendic.remission_expiration_config'::regclass
|
||||
) THEN
|
||||
ALTER TABLE gepafin_rendic.remission_expiration_config
|
||||
ADD CONSTRAINT uq_expiration_config_type_days UNIQUE (type, interval_days);
|
||||
END IF;
|
||||
END$$;
|
||||
INSERT INTO gepafin_rendic.remission_expiration_config (type, interval_days)
|
||||
VALUES ('AMENDMENT', 7), ('AMENDMENT', 2)
|
||||
ON CONFLICT (type, interval_days) DO NOTHING;
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -199,6 +199,11 @@ class RemissionDocument(Base):
|
||||
sha256 = Column(String(64), nullable=True)
|
||||
uploaded_by = Column(Integer, nullable=True)
|
||||
|
||||
# Link al repository documenti della company (gepafin_schema.company_document).
|
||||
# Se valorizzato, il documento e stato selezionato dal picker repository invece
|
||||
# che caricato dal PC. filename/expires_at vengono copiati al momento del link.
|
||||
source_company_document_id = Column(Integer, nullable=True)
|
||||
|
||||
# Campi istruttoria
|
||||
verification_status = Column(String(16), nullable=False, default="PENDING")
|
||||
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
||||
@@ -230,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.
|
||||
|
||||
@@ -36,9 +36,10 @@ def assignments_overview(
|
||||
manager: AuthUser = Depends(_require_manager),
|
||||
):
|
||||
"""Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'."""
|
||||
practices = db.query(RemissionPractice).filter(
|
||||
RemissionPractice.status.in_(["SUBMITTED", "UNDER_REVIEW", "AWAITING_AMENDMENT"])
|
||||
).order_by(
|
||||
# Vista manager: tutte le pratiche (incluso DRAFT in compilazione dal benef e
|
||||
# APPROVED/REJECTED chiuse) perche il capo istruttore deve vedere tutto per
|
||||
# riassegnare, monitorare carico, verificare storici.
|
||||
practices = db.query(RemissionPractice).order_by(
|
||||
RemissionPractice.application_id,
|
||||
RemissionPractice.sequence_number
|
||||
).all()
|
||||
|
||||
@@ -36,16 +36,16 @@ def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPr
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
||||
# Autorizzazione base: beneficiario owner o istruttore
|
||||
if user.is_beneficiary() and p.user_id != user.user_id:
|
||||
if user.is_owner_role() and p.user_id != user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Accesso negato")
|
||||
if not user.is_beneficiary() and not _is_instructor(user):
|
||||
if not user.is_owner_role() and not _is_instructor(user):
|
||||
raise HTTPException(status_code=403, detail="Ruolo non autorizzato")
|
||||
return p
|
||||
|
||||
|
||||
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
|
||||
if not user.is_beneficiary():
|
||||
if not user.is_owner_role():
|
||||
return False
|
||||
if practice.user_id != user.user_id:
|
||||
return False
|
||||
|
||||
@@ -11,7 +11,9 @@ from uuid import UUID
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
@@ -36,7 +38,7 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||
"""Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
|
||||
if _is_instructor(user):
|
||||
return True
|
||||
if user.is_beneficiary() and practice.user_id == user.user_id:
|
||||
if user.is_owner_role() and practice.user_id == user.user_id:
|
||||
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||
return False
|
||||
|
||||
@@ -44,14 +46,14 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||
if _is_instructor(user):
|
||||
return True
|
||||
if user.is_beneficiary() and practice.user_id == user.user_id:
|
||||
if user.is_owner_role() and practice.user_id == user.user_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file."""
|
||||
if user.is_beneficiary() and practice.user_id == user.user_id:
|
||||
if user.is_owner_role() and practice.user_id == user.user_id:
|
||||
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||
if user.is_superadmin():
|
||||
return True
|
||||
@@ -267,3 +269,97 @@ def delete_entity_file(
|
||||
|
||||
db.commit()
|
||||
return ApiResponse(success=True, message="File eliminato")
|
||||
|
||||
|
||||
# ---------- Link da repository company ----------
|
||||
# 2026-04-20: riutilizzo documenti caricati in fase domanda.
|
||||
# Il benef seleziona un documento dal proprio repository company invece di caricarlo
|
||||
# dal PC. Non c'e upload fisico: copiamo solo i metadati (filename, expires_at,
|
||||
# storage_path per preview/download) e tracciamo source_company_document_id per
|
||||
# permettere lookup live dello status sorgente (VALID/DUE/EXPIRED).
|
||||
class LinkFromRepositoryRequest(BaseModel):
|
||||
company_document_id: int
|
||||
|
||||
|
||||
@router.post("/document/{entity_id}/link-from-repository", response_model=ApiResponse)
|
||||
def link_document_from_repository(
|
||||
entity_id: UUID,
|
||||
body: LinkFromRepositoryRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Associa un remission_document esistente a un company_document del repository
|
||||
della fase domanda. Sostituisce eventuali file precedenti caricati dal PC
|
||||
(elimina dallo storage, azzera storage_path).
|
||||
"""
|
||||
# 1) carica il remission_document e verifica permesso upload (benef owner o admin)
|
||||
entity = db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first()
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail="Documento non trovato")
|
||||
|
||||
practice = db.query(RemissionPractice).filter(RemissionPractice.id == entity.practice_id).first()
|
||||
if not practice:
|
||||
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||
if not _can_upload(user, practice):
|
||||
raise HTTPException(status_code=403, detail="Non autorizzato")
|
||||
|
||||
# pratica editabile solo in DRAFT (stessa regola dell'upload)
|
||||
if practice.status != "DRAFT":
|
||||
raise HTTPException(status_code=409, detail=f"Pratica in stato {practice.status}: non modificabile")
|
||||
|
||||
# 2) leggi il company_document (deve esistere, stessa company della pratica, non eliminato)
|
||||
row = db.execute(text("""
|
||||
SELECT id, file_name, file_path, type, status, expiration_date, company_id
|
||||
FROM gepafin_schema.company_document
|
||||
WHERE id = :cid AND is_deleted = false
|
||||
"""), {"cid": body.company_document_id}).mappings().first()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"company_document {body.company_document_id} non trovato")
|
||||
|
||||
if row["company_id"] != practice.company_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Documento repository non appartiene alla company di questa pratica"
|
||||
)
|
||||
|
||||
# 3) se c'era un file fisico caricato dal PC in precedenza, lo rimuoviamo per pulizia
|
||||
if entity.storage_path and not entity.source_company_document_id:
|
||||
try:
|
||||
delete_file(entity.storage_path)
|
||||
except Exception:
|
||||
pass # non bloccare se il file non c'e piu
|
||||
|
||||
# 4) aggiorna metadati con quelli del repository
|
||||
from datetime import datetime, timezone, date
|
||||
entity.source_company_document_id = row["id"]
|
||||
entity.filename = row["file_name"]
|
||||
entity.storage_path = row["file_path"] # riuso del path fisico del BE per preview/download
|
||||
entity.mime = None
|
||||
entity.size_bytes = None
|
||||
entity.sha256 = None
|
||||
entity.uploaded_by = user.user_id
|
||||
entity.uploaded_at = datetime.now(timezone.utc)
|
||||
# scadenza dal sorgente (timestamp -> date)
|
||||
exp = row["expiration_date"]
|
||||
if exp is not None:
|
||||
entity.expires_at = exp.date() if hasattr(exp, 'date') else exp
|
||||
else:
|
||||
entity.expires_at = None
|
||||
|
||||
db.commit()
|
||||
db.refresh(entity)
|
||||
|
||||
return ApiResponse(
|
||||
success=True,
|
||||
message=f"Documento collegato dal repository (source_status={row['status']})",
|
||||
data={
|
||||
"id": str(entity.id),
|
||||
"doc_code": entity.doc_code,
|
||||
"filename": entity.filename,
|
||||
"source_company_document_id": entity.source_company_document_id,
|
||||
"expires_at": entity.expires_at.isoformat() if entity.expires_at else None,
|
||||
"source_status": row["status"], # VALID | DUE | EXPIRED — per UI semaforo
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,15 +6,16 @@ from decimal import Decimal
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, or_, and_
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user
|
||||
from ..storage import save_upload, FileTooLargeError, MimeNotAllowedError, StorageError
|
||||
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,
|
||||
@@ -88,8 +89,14 @@ def instructor_queue(db: Session = Depends(get_db), user: AuthUser = Depends(_re
|
||||
)
|
||||
if not manager:
|
||||
# solo: SUBMITTED non assegnate OR UNDER_REVIEW assegnate a me OR AWAITING_AMENDMENT assegnate a me
|
||||
# Un istruttore vede in coda:
|
||||
# - SUBMITTED non assegnate (pool da prendere in carico)
|
||||
# - SUBMITTED pre-assegnate a lui (suggested da gepafin_schema.assigned_applications)
|
||||
# - UNDER_REVIEW in lavorazione a lui
|
||||
# - AWAITING_AMENDMENT in attesa di risposta beneficiario
|
||||
q = q.filter(or_(
|
||||
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id.is_(None)),
|
||||
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id == user.user_id),
|
||||
and_(RemissionPractice.status == "UNDER_REVIEW", RemissionPractice.assigned_instructor_id == user.user_id),
|
||||
and_(RemissionPractice.status == "AWAITING_AMENDMENT", RemissionPractice.assigned_instructor_id == user.user_id),
|
||||
))
|
||||
@@ -189,10 +196,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,
|
||||
@@ -200,11 +226,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,
|
||||
@@ -212,37 +240,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"
|
||||
|
||||
@@ -252,6 +385,59 @@ def close_amendment(practice_id: UUID, amendment_id: UUID,
|
||||
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse)
|
||||
async def upload_amendment_document(practice_id: UUID, amendment_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(_require_instructor)):
|
||||
"""Allega documento dell'istruttore al soccorso (motivazione, scheda tecnica, ecc.).
|
||||
Consentito in DRAFT o AWAITING. Sostituisce il precedente se esiste."""
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status not in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value):
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Upload consentito in DRAFT/AWAITING (attuale: {ar.status})")
|
||||
p = _get_practice_or_404(db, practice_id)
|
||||
try:
|
||||
rel_path, size, digest, mime, safe_name = save_upload(
|
||||
application_id=p.application_id,
|
||||
entity_type="amendment-instructor-doc",
|
||||
entity_id=ar.id,
|
||||
file_obj=file.file,
|
||||
original_filename=file.filename or "amendment.pdf",
|
||||
content_type=file.content_type,
|
||||
)
|
||||
except FileTooLargeError as e:
|
||||
raise HTTPException(status_code=413, detail=str(e))
|
||||
except MimeNotAllowedError as e:
|
||||
raise HTTPException(status_code=415, detail=str(e))
|
||||
except StorageError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Errore storage: {e}")
|
||||
|
||||
ar.amendment_document_path = rel_path
|
||||
ar.amendment_document_type = mime
|
||||
db.commit()
|
||||
db.refresh(ar)
|
||||
return ApiResponse(message="Documento allegato al soccorso",
|
||||
data={"amendment_id": str(ar.id), "path": rel_path,
|
||||
"filename": safe_name, "size_bytes": size, "mime": mime})
|
||||
|
||||
|
||||
@router.delete("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse)
|
||||
def delete_amendment_document(practice_id: UUID, amendment_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(_require_instructor)):
|
||||
"""Rimuove il documento istruttore allegato al soccorso (consentito solo in DRAFT)."""
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status != AmendmentStatus.DRAFT.value:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Rimozione allegato consentita solo in DRAFT (attuale: {ar.status})")
|
||||
ar.amendment_document_path = None
|
||||
ar.amendment_document_type = None
|
||||
db.commit()
|
||||
return ApiResponse(message="Documento allegato rimosso",
|
||||
data={"amendment_id": str(ar.id)})
|
||||
|
||||
|
||||
# Endpoint beneficiario: visualizza amendments sulla sua pratica + risponde
|
||||
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
|
||||
def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
|
||||
@@ -260,15 +446,10 @@ def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
|
||||
user: AuthUser = Depends(get_current_user)):
|
||||
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
|
||||
p = _get_practice_or_404(db, practice_id)
|
||||
if user.is_beneficiary() and p.user_id != user.user_id:
|
||||
if user.is_owner_role() 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")
|
||||
|
||||
@@ -429,3 +610,44 @@ def set_instructor_final_notes(practice_id: UUID, body: InstructorFinalNotesBody
|
||||
db.refresh(p)
|
||||
return ApiResponse(message="Verbale aggiornato",
|
||||
data=PracticeOut.model_validate(p).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/{practice_id}/amendment/{amendment_id}/upload-response-document", response_model=ApiResponse)
|
||||
async def upload_response_document(practice_id: UUID, amendment_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(get_current_user)):
|
||||
"""Beneficiario allega un documento come supporto alla sua risposta al soccorso.
|
||||
Consentito su amendment in stato AWAITING, solo dal proprietario pratica."""
|
||||
p = _get_practice_or_404(db, practice_id)
|
||||
if user.is_owner_role() and p.user_id != user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
||||
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status not in (AmendmentStatus.AWAITING.value, AmendmentStatus.RESPONSE_RECEIVED.value):
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Upload risposta consentito solo in AWAITING/RESPONSE_RECEIVED (attuale: {ar.status})")
|
||||
|
||||
try:
|
||||
rel_path, size, digest, mime, safe_name = save_upload(
|
||||
application_id=p.application_id,
|
||||
entity_type="amendment-response-doc",
|
||||
entity_id=ar.id,
|
||||
file_obj=file.file,
|
||||
original_filename=file.filename or "response.pdf",
|
||||
content_type=file.content_type,
|
||||
)
|
||||
except FileTooLargeError as e:
|
||||
raise HTTPException(status_code=413, detail=str(e))
|
||||
except MimeNotAllowedError as e:
|
||||
raise HTTPException(status_code=415, detail=str(e))
|
||||
except StorageError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Errore storage: {e}")
|
||||
|
||||
ar.response_document_path = rel_path
|
||||
ar.response_document_type = mime
|
||||
db.commit()
|
||||
db.refresh(ar)
|
||||
return ApiResponse(message="Documento risposta allegato",
|
||||
data={"amendment_id": str(ar.id), "path": rel_path,
|
||||
"filename": safe_name, "size_bytes": size, "mime": mime})
|
||||
|
||||
267
app/routers/internal.py
Normal file
267
app/routers/internal.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""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 fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, text
|
||||
|
||||
from ..storage import BASE_PATH, StorageError
|
||||
|
||||
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]})
|
||||
|
||||
|
||||
def _resolve_amendment_file(amendment_id: UUID, db: Session,
|
||||
kind: str) -> tuple:
|
||||
"""Risolve il path fisico di un allegato amendment.
|
||||
|
||||
kind: 'instructor' (amendment_document_path) | 'response' (response_document_path).
|
||||
Ritorna tuple (abs_path, mime, safe_filename_hint).
|
||||
Solleva HTTPException(404) se amendment non esiste o non ha il file.
|
||||
Hardening: path deve restare dentro BASE_PATH (no traversal).
|
||||
"""
|
||||
ar = db.query(RemissionAmendmentRequest).filter(
|
||||
RemissionAmendmentRequest.id == amendment_id
|
||||
).first()
|
||||
if not ar:
|
||||
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
||||
|
||||
if kind == "instructor":
|
||||
rel_path, mime = ar.amendment_document_path, ar.amendment_document_type
|
||||
elif kind == "response":
|
||||
rel_path, mime = ar.response_document_path, ar.response_document_type
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail=f"kind invalido: {kind}")
|
||||
|
||||
if not rel_path:
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"Nessun documento {kind} per amendment {amendment_id}")
|
||||
|
||||
abs_path = BASE_PATH / rel_path
|
||||
try:
|
||||
abs_path.resolve().relative_to(BASE_PATH.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=500, detail="Path non valido (hardening)")
|
||||
|
||||
if not abs_path.is_file():
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"File non presente su storage: {rel_path}")
|
||||
|
||||
# filename hint = tail dopo l'ultimo "-" (struttura {sha}-{nome.ext})
|
||||
base = abs_path.name
|
||||
if "-" in base:
|
||||
safe_name = base.split("-", 1)[1]
|
||||
else:
|
||||
safe_name = base
|
||||
return abs_path, (mime or "application/pdf"), safe_name
|
||||
|
||||
|
||||
@router.get("/{amendment_id}/document")
|
||||
def get_amendment_document(
|
||||
amendment_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
_: bool = Depends(_check_internal_auth),
|
||||
):
|
||||
"""Scarica il PDF allegato dall'istruttore al soccorso (binary stream).
|
||||
Usato dal poller BE per archiviare su S3 nel folder pratica.
|
||||
"""
|
||||
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "instructor")
|
||||
return FileResponse(
|
||||
path=str(abs_path), media_type=mime,
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{amendment_id}/response-document")
|
||||
def get_response_document(
|
||||
amendment_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
_: bool = Depends(_check_internal_auth),
|
||||
):
|
||||
"""Scarica il PDF allegato dal beneficiario come risposta (binary stream).
|
||||
Usato dal poller BE per archiviare su S3 nel folder pratica.
|
||||
"""
|
||||
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "response")
|
||||
return FileResponse(
|
||||
path=str(abs_path), media_type=mime,
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
|
||||
)
|
||||
@@ -39,7 +39,7 @@ def _get_practice_or_404(db: Session, practice_id: UUID, user: AuthUser) -> Remi
|
||||
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
||||
|
||||
# Solo il beneficiario owner o un superadmin può accedere
|
||||
if user.is_beneficiary() and practice.user_id != user.user_id:
|
||||
if user.is_owner_role() and practice.user_id != user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Accesso negato a questa pratica")
|
||||
|
||||
return practice
|
||||
@@ -216,6 +216,30 @@ def _compute_gate_check(db: Session, practice: RemissionPractice) -> GateCheckRe
|
||||
"detail": f"Mancanti: {', '.join(missing_docs)}" if missing_docs else "Tutti presenti"
|
||||
})
|
||||
|
||||
# Check 5b: documenti non scaduti (gate hard su EXPIRED)
|
||||
# 2026-04-20: documento EXPIRED blocca la submit. Status letto live via JOIN sul
|
||||
# BE Gepafin per doc collegati dal repository; per upload diretto PC controlla expires_at.
|
||||
from datetime import date as _date_today_cls
|
||||
today = _date_today_cls.today()
|
||||
expired_docs = []
|
||||
for doc in practice.documents:
|
||||
if doc.source_company_document_id:
|
||||
cd_status = db.execute(text("""
|
||||
SELECT status FROM gepafin_schema.company_document
|
||||
WHERE id = :cid AND is_deleted = false
|
||||
"""), {"cid": doc.source_company_document_id}).scalar()
|
||||
if cd_status == 'EXPIRED':
|
||||
expired_docs.append(doc.doc_code)
|
||||
elif doc.expires_at is not None and doc.expires_at < today:
|
||||
expired_docs.append(doc.doc_code)
|
||||
|
||||
checks.append({
|
||||
"id": "documents_not_expired",
|
||||
"label": "Nessun documento scaduto",
|
||||
"passed": len(expired_docs) == 0,
|
||||
"detail": f"Scaduti: {', '.join(expired_docs)}" if expired_docs else "Tutti validi"
|
||||
})
|
||||
|
||||
# Check 6: importo range (cap erogato)
|
||||
amt_range = rules.get("amount_range", {})
|
||||
min_e = Decimal(str(amt_range.get("min", 0)))
|
||||
@@ -414,7 +438,7 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
||||
if app_row["status"] != "CONTRACT_SIGNED":
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
|
||||
if user.is_beneficiary() and app_row["user_id"] != user.user_id:
|
||||
if user.is_owner_role() and app_row["user_id"] != user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Application non di tua proprieta")
|
||||
|
||||
# Schema del bando
|
||||
@@ -422,7 +446,7 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
||||
if not schema:
|
||||
raise HTTPException(status_code=409,
|
||||
detail="Nessuno schema di rendicontazione configurato per questo bando.")
|
||||
if schema.status != "PUBLISHED" and user.is_beneficiary():
|
||||
if schema.status != "PUBLISHED" and user.is_owner_role():
|
||||
raise HTTPException(status_code=409,
|
||||
detail="Lo schema di rendicontazione non e ancora stato pubblicato.")
|
||||
|
||||
|
||||
@@ -1,19 +1,153 @@
|
||||
"""
|
||||
Endpoint gestione schema rendicontazione per bando.
|
||||
v2.1 (2026-04-18): picker multi-sorgente (blank, template predefinito, clone da altro bando).
|
||||
"""
|
||||
import copy
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user, require_superadmin
|
||||
from ..models import CallRemissionSchema
|
||||
from ..schemas import RemissionSchemaOut, RemissionSchemaCreate, RemissionSchemaUpdate, ApiResponse
|
||||
from ..templates import RESTART_TEMPLATE
|
||||
from ..templates import (
|
||||
RESTART_TEMPLATE,
|
||||
TEMPLATES,
|
||||
list_templates,
|
||||
get_template,
|
||||
upgrade_schema_to_v2,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/rendicontazione-schemas", tags=["rendicontazione-schemas"])
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# PICKER: template predefiniti + clonable calls
|
||||
# =========================================================================
|
||||
|
||||
@router.get("/templates", response_model=ApiResponse)
|
||||
def get_available_templates(user: AuthUser = Depends(require_superadmin)):
|
||||
"""Elenca i template predefiniti disponibili (blank, restart, ...).
|
||||
Ritorna solo metadati (template_id, label, description) — lo schema completo e con /templates/{id}."""
|
||||
return ApiResponse(data={"templates": list_templates()})
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=ApiResponse)
|
||||
def get_template_preview(template_id: str, user: AuthUser = Depends(require_superadmin)):
|
||||
"""Preview dello schema completo di un template. Non crea nulla."""
|
||||
schema = get_template(template_id)
|
||||
if schema is None:
|
||||
raise HTTPException(status_code=404, detail=f"Template '{template_id}' non trovato")
|
||||
return ApiResponse(data=schema)
|
||||
|
||||
|
||||
@router.get("/clonable-calls", response_model=ApiResponse)
|
||||
def list_clonable_calls(
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(require_superadmin),
|
||||
):
|
||||
"""Elenca i bandi che hanno gia uno schema di rendicontazione (DRAFT o PUBLISHED),
|
||||
disponibili come sorgenti di clonazione. Esclude il bando ancora non deciso dal context."""
|
||||
rows = db.execute(text("""
|
||||
SELECT c.id AS call_id, c.name, c.status AS call_status,
|
||||
s.status AS schema_status, s.created_at AS schema_created_at
|
||||
FROM gepafin_rendic.call_remission_schema s
|
||||
JOIN gepafin_schema.call c ON c.id = s.call_id AND c.is_deleted = false
|
||||
ORDER BY s.created_at DESC
|
||||
""")).mappings().all()
|
||||
return ApiResponse(data={"calls": [dict(r) for r in rows]})
|
||||
|
||||
|
||||
class SchemaInitializeRequest(BaseModel):
|
||||
source: str = Field(..., description="blank | template | clone")
|
||||
template_id: Optional[str] = Field(None, description="se source=template")
|
||||
source_call_id: Optional[int] = Field(None, description="se source=clone")
|
||||
|
||||
|
||||
@router.post("/{call_id}/initialize", response_model=ApiResponse)
|
||||
def initialize_schema(
|
||||
call_id: int,
|
||||
body: SchemaInitializeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(require_superadmin),
|
||||
):
|
||||
"""Inizializza uno schema di rendicontazione per call_id in modalita:
|
||||
- blank: schema vuoto con solo scheletro sezioni
|
||||
- template: parte da template predefinito (body.template_id)
|
||||
- clone: copia schema_json di un altro bando (body.source_call_id)
|
||||
Lo schema risultante e sempre DRAFT. Fallisce 409 se esiste gia.
|
||||
"""
|
||||
# Target vuoto
|
||||
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per modificarlo.",
|
||||
)
|
||||
|
||||
# Verifica che il bando target esista
|
||||
target = db.execute(text("""
|
||||
SELECT id, name FROM gepafin_schema.call
|
||||
WHERE id = :cid AND is_deleted = false
|
||||
"""), {"cid": call_id}).mappings().first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail=f"Bando call_id={call_id} non trovato")
|
||||
|
||||
# Risolvi schema_json secondo source
|
||||
source = (body.source or "").lower()
|
||||
|
||||
if source == "blank":
|
||||
schema_json = get_template("blank")
|
||||
msg = f"Schema vuoto inizializzato per call_id={call_id}"
|
||||
|
||||
elif source == "template":
|
||||
tpl_id = body.template_id or ""
|
||||
schema_json = get_template(tpl_id)
|
||||
if schema_json is None:
|
||||
raise HTTPException(status_code=422,
|
||||
detail=f"template_id '{tpl_id}' non valido. Usa GET /templates per l'elenco.")
|
||||
msg = f"Schema inizializzato da template '{tpl_id}' per call_id={call_id}"
|
||||
|
||||
elif source == "clone":
|
||||
src_id = body.source_call_id
|
||||
if not src_id:
|
||||
raise HTTPException(status_code=422, detail="source_call_id richiesto per source=clone")
|
||||
src_schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == src_id).first()
|
||||
if not src_schema:
|
||||
raise HTTPException(status_code=404,
|
||||
detail=f"Schema sorgente non trovato per source_call_id={src_id}")
|
||||
schema_json = copy.deepcopy(src_schema.schema_json)
|
||||
# assicura upgrade a v2 se il sorgente era v1
|
||||
schema_json = upgrade_schema_to_v2(schema_json)
|
||||
msg = f"Schema clonato da call_id={src_id} per call_id={call_id}"
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=422,
|
||||
detail="source deve essere 'blank', 'template' o 'clone'")
|
||||
|
||||
# Persisti nuovo schema DRAFT
|
||||
schema = CallRemissionSchema(
|
||||
call_id=call_id,
|
||||
schema_json=schema_json,
|
||||
status="DRAFT",
|
||||
created_by=user.user_id,
|
||||
)
|
||||
db.add(schema)
|
||||
db.commit()
|
||||
db.refresh(schema)
|
||||
return ApiResponse(
|
||||
message=msg,
|
||||
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Endpoint esistenti (CRUD, publish, delete)
|
||||
# =========================================================================
|
||||
|
||||
@router.get("/{call_id}", response_model=ApiResponse)
|
||||
def get_schema(call_id: int, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
|
||||
"""Legge lo schema di rendicontazione per un bando. 404 se non esiste."""
|
||||
@@ -33,12 +167,12 @@ def create_schema(
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(require_superadmin),
|
||||
):
|
||||
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste già."""
|
||||
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste gia."""
|
||||
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
|
||||
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
|
||||
)
|
||||
schema = CallRemissionSchema(
|
||||
call_id=call_id,
|
||||
@@ -62,7 +196,7 @@ def update_schema(
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(require_superadmin),
|
||||
):
|
||||
"""Aggiorna schema esistente. Blocca modifiche se già PUBLISHED."""
|
||||
"""Aggiorna schema esistente. Blocca modifiche se gia PUBLISHED."""
|
||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||
if not schema:
|
||||
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}")
|
||||
@@ -87,26 +221,10 @@ def initialize_restart(
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(require_superadmin),
|
||||
):
|
||||
"""Inizializza schema per un bando usando il template RE-START. Fallisce se esiste già."""
|
||||
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per modificarlo.",
|
||||
)
|
||||
schema = CallRemissionSchema(
|
||||
call_id=call_id,
|
||||
schema_json=copy.deepcopy(RESTART_TEMPLATE),
|
||||
status="DRAFT",
|
||||
created_by=user.user_id,
|
||||
)
|
||||
db.add(schema)
|
||||
db.commit()
|
||||
db.refresh(schema)
|
||||
return ApiResponse(
|
||||
message=f"Schema RE-START inizializzato per call_id={call_id}",
|
||||
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
|
||||
)
|
||||
"""DEPRECATO in 2.1 — alias di /initialize con source=template&template_id=restart.
|
||||
Mantenuto per backward compatibility del FE."""
|
||||
body = SchemaInitializeRequest(source="template", template_id="restart")
|
||||
return initialize_schema(call_id=call_id, body=body, db=db, user=user)
|
||||
|
||||
|
||||
@router.post("/{call_id}/publish", response_model=ApiResponse)
|
||||
@@ -115,7 +233,7 @@ def publish_schema(
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(require_superadmin),
|
||||
):
|
||||
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non è più editabile."""
|
||||
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non e piu editabile."""
|
||||
from datetime import datetime, timezone
|
||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||
if not schema:
|
||||
@@ -123,7 +241,7 @@ def publish_schema(
|
||||
if schema.status == "PUBLISHED":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Schema già pubblicato.",
|
||||
detail="Schema gia pubblicato.",
|
||||
)
|
||||
schema.status = "PUBLISHED"
|
||||
schema.published_at = datetime.now(timezone.utc)
|
||||
@@ -156,7 +274,7 @@ def delete_schema(
|
||||
return ApiResponse(message=f"Schema cancellato per call_id={call_id}")
|
||||
|
||||
|
||||
@router.get("/templates/restart", response_model=ApiResponse)
|
||||
@router.get("/templates-preview/restart", response_model=ApiResponse)
|
||||
def get_restart_template(user: AuthUser = Depends(require_superadmin)):
|
||||
"""Restituisce il template RE-START senza persisterlo. Utile per preview."""
|
||||
"""DEPRECATO: usa GET /templates/restart invece. Mantenuto per backward compat."""
|
||||
return ApiResponse(data=RESTART_TEMPLATE)
|
||||
|
||||
@@ -16,13 +16,16 @@ from sqlalchemy import text
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user
|
||||
from ..models import RemissionPractice
|
||||
from ..models import RemissionPractice, RemissionCustomCheckValue
|
||||
from .practices import _compute_gate_check
|
||||
|
||||
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"])
|
||||
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates_jinja"
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
# URL file:// assoluto per weasyprint (che non fa HTTP fetch ma risolve file://)
|
||||
LOGO_FILE_URL = f"file://{STATIC_DIR.resolve()}/gepafin-logo.svg"
|
||||
|
||||
|
||||
# ---------- Jinja env & filters ----------
|
||||
@@ -177,6 +180,50 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
|
||||
"""), {"uid": user.user_id}).scalar()
|
||||
instructor_name = row
|
||||
|
||||
# v2: custom_checks merged (schema_snapshot.custom_checks[] + RemissionCustomCheckValue)
|
||||
check_defs = practice.schema_snapshot.get("custom_checks") or []
|
||||
values_by_code = {v.check_code: v for v in practice.custom_checks}
|
||||
custom_checks_merged = []
|
||||
for d in check_defs:
|
||||
code = d.get("code")
|
||||
val = values_by_code.get(code)
|
||||
custom_checks_merged.append({
|
||||
"code": code,
|
||||
"label": d.get("label"),
|
||||
"description": d.get("description"),
|
||||
"requires_document": bool(d.get("requires_document")),
|
||||
"required": bool(d.get("required")),
|
||||
"beneficiary_declared": bool(val.beneficiary_declared) if val else False,
|
||||
"declared_at": val.declared_at if val else None,
|
||||
"has_document": bool(val and val.storage_path),
|
||||
"verification_status": (val.verification_status if val else "PENDING"),
|
||||
"verification_notes": (val.verification_notes if val else None),
|
||||
})
|
||||
|
||||
# v2: storico tranche precedenti APPROVED (se sequence > 1)
|
||||
previous_tranches = []
|
||||
cumulative_approved = 0.0
|
||||
if practice.sequence_number > 1:
|
||||
prevs = db.query(RemissionPractice).filter(
|
||||
RemissionPractice.application_id == practice.application_id,
|
||||
RemissionPractice.sequence_number < practice.sequence_number,
|
||||
RemissionPractice.status == "APPROVED",
|
||||
).order_by(RemissionPractice.sequence_number).all()
|
||||
for pv in prevs:
|
||||
amt = float(pv.approved_remission or 0)
|
||||
cumulative_approved += amt
|
||||
previous_tranches.append({
|
||||
"sequence_number": pv.sequence_number,
|
||||
"period_label": pv.period_label,
|
||||
"reviewed_at": pv.reviewed_at,
|
||||
"approved_remission": amt,
|
||||
"cumulative": cumulative_approved,
|
||||
})
|
||||
|
||||
# v2 max_tranches dallo schema_snapshot (o dal bando corrente, fallback 1)
|
||||
snap_rules = practice.schema_snapshot.get("gate_rules") or {}
|
||||
max_tranches_snapshot = int(snap_rules.get("max_tranches") or totals.get("tranches_max") or 1)
|
||||
|
||||
return {
|
||||
"practice": practice,
|
||||
"totals": totals,
|
||||
@@ -196,6 +243,11 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
|
||||
"company": company,
|
||||
"instructor_name": instructor_name,
|
||||
"generated_at": datetime.now().strftime("%d/%m/%Y"),
|
||||
# v2
|
||||
"custom_checks_merged": custom_checks_merged,
|
||||
"previous_tranches": previous_tranches,
|
||||
"max_tranches_snapshot": max_tranches_snapshot,
|
||||
"logo_path": LOGO_FILE_URL,
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +286,7 @@ def verbale_pdf(
|
||||
|
||||
practice, html = _render_html(db, practice_id, user)
|
||||
pdf_bytes = WeasyHTML(string=html).write_pdf()
|
||||
filename = f"verbale_istruttoria_pratica_{practice.application_id}.pdf"
|
||||
filename = f"verbale_istruttoria_pratica_{practice.application_id}_t{practice.sequence_number}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
||||
158
app/scheduler.py
Normal file
158
app/scheduler.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Scheduler per lifecycle amendment.
|
||||
|
||||
Due cron job attivi:
|
||||
- expire_amendments(): ogni giorno 01:05 — trova amendment AWAITING con
|
||||
deadline < today, le passa a EXPIRED, rimette pratica a UNDER_REVIEW
|
||||
se non ci sono altri amendment aperti. Equivalente a
|
||||
ApplicationAmendmentScheduler.processAmendmentExpirationScheduler del BE.
|
||||
|
||||
- queue_reminders(): ogni giorno 09:00 — legge remission_expiration_config
|
||||
(type='AMENDMENT', interval_days=N), trova amendment AWAITING con
|
||||
deadline == today + N giorni, setta flag reminder_queued_at sul
|
||||
record. Equivalente a ExpirationScheduler.processAmendmentExpiration
|
||||
del BE (data-driven da expiration_config).
|
||||
|
||||
Le notifiche effettive (PEC reminder benef + email interna istruttore) le
|
||||
invia il BE Gepafin via polling sui nostri endpoint /internal. Il
|
||||
microservizio resta sender-agnostico.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone, date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from .db import SessionLocal
|
||||
from .models import RemissionAmendmentRequest, RemissionPractice, RemissionExpirationConfig
|
||||
|
||||
log = logging.getLogger("rendicontazione-api.scheduler")
|
||||
|
||||
|
||||
def expire_amendments() -> dict:
|
||||
"""Espira amendment AWAITING con deadline passata.
|
||||
Ritorna dict {expired_count, practices_reopened} per logging/test."""
|
||||
db: Session = SessionLocal()
|
||||
today = date.today()
|
||||
stats = {"expired_count": 0, "practices_reopened": 0}
|
||||
try:
|
||||
expired_ars = db.query(RemissionAmendmentRequest).filter(
|
||||
RemissionAmendmentRequest.status == "AWAITING",
|
||||
RemissionAmendmentRequest.deadline < today,
|
||||
).all()
|
||||
|
||||
practice_ids_to_check = set()
|
||||
|
||||
for ar in expired_ars:
|
||||
ar.status = "EXPIRED"
|
||||
practice_ids_to_check.add(ar.practice_id)
|
||||
stats["expired_count"] += 1
|
||||
log.info(f"Amendment {ar.id} EXPIRED (deadline era {ar.deadline})")
|
||||
|
||||
db.flush()
|
||||
|
||||
for pid in practice_ids_to_check:
|
||||
p = db.query(RemissionPractice).filter(RemissionPractice.id == pid).first()
|
||||
if not p:
|
||||
continue
|
||||
others_open = [
|
||||
a for a in p.amendment_requests
|
||||
if a.status in ("DRAFT", "AWAITING", "RESPONSE_RECEIVED")
|
||||
]
|
||||
if not others_open and p.status == "AWAITING_AMENDMENT":
|
||||
p.status = "UNDER_REVIEW"
|
||||
stats["practices_reopened"] += 1
|
||||
log.info(f"Pratica {pid} ritornata a UNDER_REVIEW (amendment scaduto, nessun altro aperto)")
|
||||
|
||||
db.commit()
|
||||
log.info(f"expire_amendments: {stats}")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
log.error(f"expire_amendments FAILED: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
return stats
|
||||
|
||||
|
||||
def queue_reminders() -> dict:
|
||||
"""Legge config data-driven, per ogni interval_days trova amendment
|
||||
con deadline == today + N, scrive flag reminder_queued_at sull'amendment.
|
||||
Il BE vedra questi amendment come pending-reminder via /internal.
|
||||
|
||||
Usiamo campo pec_retry_after come marker unificato (gia presente):
|
||||
- NULL: nessun reminder accodato
|
||||
- timestamp: reminder accodato in questo momento, BE invia al prossimo poll
|
||||
|
||||
Ritorna dict {reminders_queued_by_interval}."""
|
||||
db: Session = SessionLocal()
|
||||
today = date.today()
|
||||
stats = {"reminders_queued": 0, "by_interval": {}}
|
||||
try:
|
||||
configs = db.query(RemissionExpirationConfig).filter(
|
||||
RemissionExpirationConfig.type == "AMENDMENT",
|
||||
RemissionExpirationConfig.is_deleted.is_(False),
|
||||
).all()
|
||||
|
||||
for cfg in configs:
|
||||
target_deadline = today + timedelta(days=cfg.interval_days)
|
||||
|
||||
ars = db.query(RemissionAmendmentRequest).filter(
|
||||
RemissionAmendmentRequest.status == "AWAITING",
|
||||
RemissionAmendmentRequest.deadline == target_deadline,
|
||||
RemissionAmendmentRequest.pec_sent_at.isnot(None), # solo se gia inviata PEC iniziale
|
||||
# evito di ri-accodare se gia accodato oggi
|
||||
RemissionAmendmentRequest.pec_retry_after.is_(None),
|
||||
).all()
|
||||
|
||||
for ar in ars:
|
||||
ar.pec_retry_after = datetime.now(timezone.utc)
|
||||
stats["reminders_queued"] += 1
|
||||
log.info(f"Amendment {ar.id} reminder accodato ({cfg.interval_days}gg alla scadenza)")
|
||||
|
||||
stats["by_interval"][cfg.interval_days] = len(ars)
|
||||
|
||||
db.commit()
|
||||
log.info(f"queue_reminders: {stats}")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
log.error(f"queue_reminders FAILED: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
return stats
|
||||
|
||||
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Avvia BackgroundScheduler con i 2 cron. Chiamato in lifespan FastAPI."""
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
log.warning("start_scheduler chiamato due volte, skip")
|
||||
return _scheduler
|
||||
_scheduler = BackgroundScheduler(timezone="Europe/Rome")
|
||||
# expire ogni notte 01:05 (dopo midnight, sicuro che today e cambiato)
|
||||
_scheduler.add_job(
|
||||
expire_amendments, CronTrigger(hour=1, minute=5),
|
||||
id="expire_amendments", replace_existing=True, misfire_grace_time=3600,
|
||||
)
|
||||
# reminder ogni mattina 09:00
|
||||
_scheduler.add_job(
|
||||
queue_reminders, CronTrigger(hour=9, minute=0),
|
||||
id="queue_reminders", replace_existing=True, misfire_grace_time=3600,
|
||||
)
|
||||
_scheduler.start()
|
||||
log.info("Scheduler avviato: expire_amendments 01:05 + queue_reminders 09:00 (Europe/Rome)")
|
||||
return _scheduler
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
log.info("Scheduler fermato")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -117,6 +118,7 @@ class DocumentUpsert(BaseModel):
|
||||
uploaded_at: Optional[datetime] = None
|
||||
expires_at: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
source_company_document_id: Optional[int] = None
|
||||
|
||||
|
||||
class DocumentOut(BaseModel):
|
||||
@@ -127,6 +129,7 @@ class DocumentOut(BaseModel):
|
||||
uploaded_at: Optional[datetime] = None
|
||||
expires_at: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
source_company_document_id: Optional[int] = None
|
||||
# istruttoria
|
||||
verification_status: str = "PENDING"
|
||||
verification_notes: Optional[str] = None
|
||||
@@ -209,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):
|
||||
@@ -233,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
|
||||
|
||||
|
||||
1
app/static/gepafin-logo.svg
Normal file
1
app/static/gepafin-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -48,7 +48,7 @@ def _safe_filename(name: str, max_len: int = 120) -> str:
|
||||
|
||||
def save_upload(
|
||||
application_id: int,
|
||||
entity_type: str, # invoice | ula | document
|
||||
entity_type: str, # invoice | ula | document | amendment-instructor-doc | amendment-response-doc
|
||||
entity_id: UUID,
|
||||
file_obj: BinaryIO,
|
||||
original_filename: str,
|
||||
@@ -62,7 +62,7 @@ def save_upload(
|
||||
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
|
||||
- dimensione <= MAX_SIZE_BYTES
|
||||
"""
|
||||
if entity_type not in ("invoice", "ula", "document"):
|
||||
if entity_type not in ("invoice", "ula", "document", "amendment-instructor-doc", "amendment-response-doc"):
|
||||
raise StorageError(f"entity_type non valido: {entity_type}")
|
||||
|
||||
safe_name = _safe_filename(original_filename)
|
||||
|
||||
126
app/templates.py
126
app/templates.py
@@ -176,3 +176,129 @@ def upgrade_schema_to_v2(schema_json: dict) -> dict:
|
||||
sec["enabled"] = True
|
||||
changed = True
|
||||
return schema_json
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# BLANK_TEMPLATE — scheletro minimo v2, solo sezioni vuote da popolare
|
||||
# =========================================================================
|
||||
BLANK_TEMPLATE = {
|
||||
"version": "2.0",
|
||||
"schema_version": 2,
|
||||
"template_id": "BLANK_V2",
|
||||
"template_label": "Nuovo schema vuoto",
|
||||
"sections": [
|
||||
{
|
||||
"type": "static_fields",
|
||||
"id": "general",
|
||||
"label": "Dati generali",
|
||||
"description": "Regime IVA, dati base del beneficiario, periodo di ammissibilita delle spese.",
|
||||
"fields": [
|
||||
{
|
||||
"id": "period_start_date",
|
||||
"type": "date",
|
||||
"label": "Periodo ammissibilita — Data inizio",
|
||||
"description": "Data minima di emissione/pagamento fatture ammissibili.",
|
||||
"editable_by": "superadmin",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"id": "period_end_date",
|
||||
"type": "date",
|
||||
"label": "Periodo ammissibilita — Data fine",
|
||||
"description": "Data massima di emissione/pagamento fatture ammissibili.",
|
||||
"editable_by": "superadmin",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"id": "iva_regime",
|
||||
"type": "select",
|
||||
"label": "Regime IVA",
|
||||
"required": True,
|
||||
"options": [
|
||||
{"value": "ORDINARIO", "label": "Ordinario — IVA non ammissibile"},
|
||||
{"value": "FORFETTARIO", "label": "Forfettario — IVA ammissibile"},
|
||||
{"value": "ESENTE", "label": "Esente"},
|
||||
],
|
||||
"help": "Il regime IVA determina se l'IVA delle fatture e rendicontabile.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "invoice_table",
|
||||
"id": "invoices",
|
||||
"label": "Fatture ammissibili",
|
||||
"description": "Categorie di spesa da configurare. Aggiungi almeno una categoria prima di pubblicare.",
|
||||
"categories": [],
|
||||
},
|
||||
{
|
||||
"type": "ula_block",
|
||||
"id": "ula",
|
||||
"label": "Incremento occupazione (ULA)",
|
||||
"description": "Dipendenti su cui calcolare l'incremento ULA. Disattiva la sezione se il bando non lo richiede.",
|
||||
"enabled": False,
|
||||
"threshold": 1.0,
|
||||
"fields": [],
|
||||
},
|
||||
{
|
||||
"type": "documents_required",
|
||||
"id": "docs",
|
||||
"label": "Documenti richiesti",
|
||||
"description": "Documenti che il beneficiario deve allegare alla rendicontazione. Aggiungi almeno i documenti obbligatori.",
|
||||
"items": [],
|
||||
},
|
||||
],
|
||||
"custom_checks": [],
|
||||
"gate_rules": {
|
||||
"invoices_min_count": 1,
|
||||
"amount_range": {"min": 0, "max": 100000},
|
||||
"cap_pct_erogato": 0.5,
|
||||
"cap_absolute": 100000,
|
||||
"amount_basis": "imponibile_only_ordinario",
|
||||
"period_start_rule": "erogato_date",
|
||||
"period_end": None,
|
||||
"require_at_least_one_invoice_per_nonzero_category": True,
|
||||
"require_ula_above_threshold": False,
|
||||
"require_all_documents_resolved": True,
|
||||
"max_tranches": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# TEMPLATES registry — esteso in futuro con nuovi bandi
|
||||
# =========================================================================
|
||||
TEMPLATES = {
|
||||
"blank": {
|
||||
"template_id": "blank",
|
||||
"label": "Nuovo schema (da zero)",
|
||||
"description": "Scheletro minimo: sezioni vuote (categorie, documenti, controlli) da popolare. Usa questo quando il bando e nuovo e non somiglia a bandi precedenti.",
|
||||
"schema": BLANK_TEMPLATE,
|
||||
},
|
||||
"restart": {
|
||||
"template_id": "restart",
|
||||
"label": "RE-START (fondo prestiti con remissione del debito)",
|
||||
"description": "Template del bando RE-START: 3 categorie B1/B2/B3 (tecnologie, ULA, formazione), sezione ULA attiva con soglia 1.0, 4 documenti standard, max 2 tranches.",
|
||||
"schema": RESTART_TEMPLATE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_templates():
|
||||
"""Restituisce i template disponibili (senza lo schema completo, solo metadati)."""
|
||||
return [
|
||||
{
|
||||
"template_id": t["template_id"],
|
||||
"label": t["label"],
|
||||
"description": t["description"],
|
||||
}
|
||||
for t in TEMPLATES.values()
|
||||
]
|
||||
|
||||
|
||||
def get_template(template_id: str):
|
||||
"""Restituisce uno schema template pronto per l'uso (deep copy)."""
|
||||
import copy
|
||||
t = TEMPLATES.get(template_id)
|
||||
if not t:
|
||||
return None
|
||||
return copy.deepcopy(t["schema"])
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
padding-bottom: 10pt;
|
||||
margin-bottom: 14pt;
|
||||
}
|
||||
.hdr__logo {
|
||||
font-size: 22pt; font-weight: 900; color: #1a365d; letter-spacing: 1pt;
|
||||
.hdr__logo-img {
|
||||
height: 38pt; width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.hdr__subtitle {
|
||||
@@ -182,10 +182,11 @@
|
||||
<div class="hdr__right">
|
||||
<div><strong>Verbale di istruttoria</strong></div>
|
||||
<div>Rendicontazione bando</div>
|
||||
<div>Pratica n. {{ practice.application_id }}</div>
|
||||
<div>Pratica n. {{ practice.application_id }}{% if max_tranches_snapshot > 1 or practice.sequence_number > 1 %} — Tranche {{ practice.sequence_number }}/{{ max_tranches_snapshot }}{% endif %}</div>
|
||||
{% if practice.period_label %}<div><small>{{ practice.period_label }}</small></div>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<span class="hdr__logo">GEPAFIN</span>
|
||||
<img src="{{ logo_path }}" alt="Gepafin" class="hdr__logo-img" />
|
||||
<div class="hdr__subtitle">Finanziaria regionale dell'Umbria</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,6 +238,15 @@
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if max_tranches_snapshot > 1 %}
|
||||
<div class="row">
|
||||
<div class="cell label">Tranche / fase</div>
|
||||
<div class="cell val">
|
||||
<strong>Tranche {{ practice.sequence_number }} di {{ max_tranches_snapshot }}</strong>
|
||||
{% if practice.period_label %} — {{ practice.period_label }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="cell label">Data presentazione</div>
|
||||
<div class="cell val">{{ practice.submitted_at|datetimefmt if practice.submitted_at else '—' }}</div>
|
||||
@@ -387,6 +397,45 @@
|
||||
<p class="text-secondary">Nessun documento richiesto dallo schema del bando.</p>
|
||||
{% endif %}
|
||||
|
||||
{# ============ CONTROLLI AGGIUNTIVI ============ #}
|
||||
{% if custom_checks_merged %}
|
||||
<h2>Controlli aggiuntivi (dichiarazioni beneficiario)</h2>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32%">Controllo</th>
|
||||
<th style="width:12%">Obbligatorio</th>
|
||||
<th style="width:10%">Dichiarato</th>
|
||||
<th style="width:11%">Doc. allegato</th>
|
||||
<th style="width:11%">Validazione</th>
|
||||
<th style="width:24%">Note istruttore</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cc in custom_checks_merged %}
|
||||
{% set stat = cc.verification_status or 'PENDING' %}
|
||||
{% set cls = 'rejected' if stat == 'NON_VALIDO' else '' %}
|
||||
{% set missing = cc.required and not cc.beneficiary_declared %}
|
||||
<tr class="{{ 'rejected' if missing else cls }}">
|
||||
<td>
|
||||
<strong>{{ cc.label or cc.code }}</strong>
|
||||
{% if cc.description %}<br><small>{{ cc.description|truncate(180) }}</small>{% endif %}
|
||||
</td>
|
||||
<td>{% if cc.required %}<span class="ko">SÌ</span>{% else %}<small class="text-secondary">opzionale</small>{% endif %}</td>
|
||||
<td>{% if cc.beneficiary_declared %}<span class="ok">SÌ</span>{% else %}<span class="ko">NO</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if cc.requires_document %}
|
||||
{% if cc.has_document %}<span class="ok">SÌ</span>{% else %}<span class="ko">NO</span>{% endif %}
|
||||
{% else %}<small class="text-secondary">non richiesto</small>{% endif %}
|
||||
</td>
|
||||
<td><span class="status-inline status-{{ stat }}">{{ stat }}</span></td>
|
||||
<td>{{ cc.verification_notes or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{# ============ SOCCORSI ============ #}
|
||||
{% if amendments %}
|
||||
<h2>Soccorso istruttorio</h2>
|
||||
@@ -406,42 +455,93 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# ============ TOTALI ============ #}
|
||||
<h2>Riepilogo finanziario</h2>
|
||||
{# ============ STORICO TRANCHES PRECEDENTI ============ #}
|
||||
{% if previous_tranches %}
|
||||
<h2>Storico tranches precedenti</h2>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:8%">#</th>
|
||||
<th style="width:35%">Periodo / fase</th>
|
||||
<th style="width:17%">Data approvazione</th>
|
||||
<th style="width:20%" class="num">Importo ammesso</th>
|
||||
<th style="width:20%" class="num">Cumulativo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in previous_tranches %}
|
||||
<tr>
|
||||
<td><strong>T{{ t.sequence_number }}</strong></td>
|
||||
<td>{{ t.period_label or '—' }}</td>
|
||||
<td>{{ t.reviewed_at|datefmt }}</td>
|
||||
<td class="num">{{ t.approved_remission|euro }}</td>
|
||||
<td class="num"><strong>{{ t.cumulative|euro }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{# ============ 5 VOCI UFFICIALI CECILIA ============ #}
|
||||
<h2>Riepilogo finanziario (cap tranche {{ practice.sequence_number }})</h2>
|
||||
|
||||
<div class="totals-summary">
|
||||
<div class="row">
|
||||
<div class="cell">
|
||||
<div class="lbl">Totale dichiarato</div>
|
||||
<div class="val">{{ totals.grand_total_declared|euro }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="lbl">Totale ammesso</div>
|
||||
<div class="val">{{ totals.grand_total_verified|euro }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="lbl">Cap remissione</div>
|
||||
<div class="val">{{ totals.max_remission|euro }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="lbl">Remissione spettante</div>
|
||||
<div class="val final">{{ totals.remission_due|euro }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if practice.status == 'APPROVED' %}
|
||||
<div class="row">
|
||||
<div class="cell" style="background: #f0fff4;">
|
||||
<div class="lbl">Remissione approvata</div>
|
||||
<div class="val" style="color: #22543d;">{{ practice.approved_remission|euro }}</div>
|
||||
</div>
|
||||
<div class="cell" style="background: #fff5f5;">
|
||||
<div class="lbl">Residuo da restituire</div>
|
||||
<div class="val residuo">{{ (practice.amount_erogato - (practice.approved_remission or 0))|euro }}</div>
|
||||
</div>
|
||||
<div class="cell" colspan="2"></div>
|
||||
<div class="cell"></div>
|
||||
</div>
|
||||
<div class="cell" style="width:50%">
|
||||
<div class="lbl">(1) Importo massimo ammissibile (cap globale)</div>
|
||||
<div class="val">{{ totals.max_remission_global|euro }}</div>
|
||||
{% if totals.already_approved_previous_tranches > 0 %}
|
||||
<div class="lbl" style="margin-top:4pt">già approvato nelle tranche precedenti</div>
|
||||
<div style="font-size:10pt; font-weight:700; color:#744210">− {{ totals.already_approved_previous_tranches|euro }}</div>
|
||||
<div class="lbl" style="margin-top:4pt">max. disponibile per questa tranche</div>
|
||||
<div style="font-size:11pt; font-weight:700; color:#2b6cb0">= {{ totals.max_remission_this_tranche|euro }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="cell" style="width:50%">
|
||||
<div class="lbl">(4) Importo finanziamento erogato</div>
|
||||
<div class="val">{{ totals.amount_erogato|euro }}</div>
|
||||
<div class="lbl" style="margin-top:6pt">tranches complessive</div>
|
||||
<div style="font-size:10pt">{{ totals.tranches_count }} / {{ totals.tranches_max }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="totals-summary" style="margin-top:8pt">
|
||||
<div class="row">
|
||||
<div class="cell" style="width:33%">
|
||||
<div class="lbl">(2) Richiesto pre-controllo (ammissibile)</div>
|
||||
<div class="val">{{ totals.pre_check_admissible|euro }}</div>
|
||||
<div class="lbl" style="margin-top:4pt">dichiarato tranche</div>
|
||||
<div style="font-size:9pt">{{ totals.grand_total_declared|euro }}</div>
|
||||
</div>
|
||||
<div class="cell" style="width:33%">
|
||||
<div class="lbl">(3) Ammesso post-controllo istruttore</div>
|
||||
<div class="val final">{{ totals.remission_due|euro }}</div>
|
||||
{% if totals.any_verified %}
|
||||
<div class="lbl" style="margin-top:4pt">verificato tranche</div>
|
||||
<div style="font-size:9pt">{{ totals.grand_total_verified|euro }}</div>
|
||||
{% else %}
|
||||
<div class="lbl" style="margin-top:4pt"><em>in attesa di verifica istruttore</em></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="cell" style="width:34%; background:#fff5f5">
|
||||
<div class="lbl">(5) Residuo da restituire</div>
|
||||
<div class="val residuo">{{ totals.residuo_da_restituire|euro }}</div>
|
||||
<div class="lbl" style="margin-top:4pt">= erogato − approvato precedente − ammesso tranche</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if practice.status == 'APPROVED' %}
|
||||
<div class="totals-summary" style="margin-top:8pt; background:#f0fff4; border-color:#68d391">
|
||||
<div class="row">
|
||||
<div class="cell" style="width:100%">
|
||||
<div class="lbl">REMISSIONE APPROVATA PER QUESTA TRANCHE</div>
|
||||
<div class="val" style="color:#22543d; font-size:18pt">{{ practice.approved_remission|euro }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ============ CHECKLIST + NOTE ============ #}
|
||||
{% set checklist = practice.instructor_checklist or {} %}
|
||||
|
||||
@@ -9,3 +9,4 @@ python-multipart==0.0.9
|
||||
weasyprint==61.2
|
||||
pydyf==0.10.0
|
||||
jinja2==3.1.3
|
||||
APScheduler==3.10.4
|
||||
|
||||
Reference in New Issue
Block a user