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:
|
Il BE Spring emette token HS512 con payload:
|
||||||
sub: "email:userId:hubId"
|
sub: "email:userId:hubId"
|
||||||
userId: int
|
userId: int
|
||||||
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ...
|
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | "ROLE_CONFIDI" | ...
|
||||||
exp: unix timestamp
|
exp: unix timestamp
|
||||||
loginAttemptId: int
|
loginAttemptId: int
|
||||||
"""
|
"""
|
||||||
@@ -29,6 +29,15 @@ class AuthUser:
|
|||||||
def is_beneficiary(self) -> bool:
|
def is_beneficiary(self) -> bool:
|
||||||
return self.role == "ROLE_BENEFICIARY"
|
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(
|
def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class Settings(BaseSettings):
|
|||||||
# CORS
|
# CORS
|
||||||
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
|
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
|
||||||
|
|
||||||
|
# Shared secret per endpoint /internal chiamati dal BE Gepafin
|
||||||
|
# In PROD va cambiato via env var RENDIC_INTERNAL_SECRET
|
||||||
|
internal_secret: str = "sandbox-internal-secret-ChangeMeInProd-AtLeast32Chars"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_prefix = "RENDIC_"
|
env_prefix = "RENDIC_"
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from sqlalchemy import text
|
|||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import engine, Base
|
from .db import engine, Base
|
||||||
from .migrations import run_migrations
|
from .migrations import run_migrations
|
||||||
from .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment
|
from .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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
log = logging.getLogger("rendicontazione-api")
|
log = logging.getLogger("rendicontazione-api")
|
||||||
@@ -32,10 +33,12 @@ async def lifespan(app: FastAPI):
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
run_migrations(engine)
|
run_migrations(engine)
|
||||||
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
|
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
|
||||||
|
start_scheduler()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Errore bootstrap DB: {e}")
|
log.error(f"Errore bootstrap DB: {e}")
|
||||||
raise
|
raise
|
||||||
yield
|
yield
|
||||||
|
stop_scheduler()
|
||||||
log.info("Shutdown rendicontazione-api")
|
log.info("Shutdown rendicontazione-api")
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ app.include_router(files.router)
|
|||||||
app.include_router(verbale.router)
|
app.include_router(verbale.router)
|
||||||
app.include_router(custom_checks.router)
|
app.include_router(custom_checks.router)
|
||||||
app.include_router(assignment.router)
|
app.include_router(assignment.router)
|
||||||
|
app.include_router(internal.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["root"])
|
@app.get("/", tags=["root"])
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ MIGRATIONS = [
|
|||||||
ON gepafin_rendic.remission_practice(assigned_instructor_id)
|
ON gepafin_rendic.remission_practice(assigned_instructor_id)
|
||||||
WHERE assigned_instructor_id IS NULL;
|
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
|
# 2026-04-18 v2: tabella custom checks
|
||||||
# allineata allo storage adapter esistente (storage_path + mime + size + sha256)
|
# allineata allo storage adapter esistente (storage_path + mime + size + sha256)
|
||||||
# NON segue le specs RAG p1 che usavano document_filename (v1 obsoleta)
|
# 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
|
CREATE INDEX IF NOT EXISTS idx_custom_check_practice
|
||||||
ON gepafin_rendic.remission_custom_check_value(practice_id);
|
ON gepafin_rendic.remission_custom_check_value(practice_id);
|
||||||
""",
|
""",
|
||||||
|
# 2026-04-20 v3: soccorso istruttorio speculare al BE Gepafin
|
||||||
|
# - stato DRAFT (istruttore prepara, non ancora inviato)
|
||||||
|
# - response_days + extended_days + extension_date (prolunghe)
|
||||||
|
# - internal_note (visibile solo istruttore, separata da request_text)
|
||||||
|
# - amendment_document_* (allegato istruttore al soccorso, firmato e no)
|
||||||
|
# - response_document_* (upload risposta beneficiario)
|
||||||
|
# - protocol_id + email_log_id + user_action_id (popolati dal BE via mark-pec-sent)
|
||||||
|
# - pec_sent_at + pec_failed_reason + pec_retry_after (tracking PEC asincrono)
|
||||||
|
# Lato microservizio NON gestiamo PEC ne protocollo: il BE multi-tenant
|
||||||
|
# (gepafin_schema.hub id=1 PEC_SERVICE, id=2 MAILGUN_SERVICE) fa polling
|
||||||
|
# su endpoint /internal/remission-amendments e notifica via mark-pec-sent/failed.
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_amendment_request
|
||||||
|
ADD COLUMN IF NOT EXISTS response_days integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS extended_days integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS extension_date timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS internal_note text,
|
||||||
|
ADD COLUMN IF NOT EXISTS amendment_document_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS amendment_document_type varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS amendment_initial_document_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS response_document_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS response_document_type varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS protocol_id varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS email_log_id integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS user_action_id integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS pec_sent_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS pec_failed_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS pec_retry_after timestamptz;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amendment_status_pec
|
||||||
|
ON gepafin_rendic.remission_amendment_request(status)
|
||||||
|
WHERE status IN ('DRAFT','AWAITING');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amendment_deadline
|
||||||
|
ON gepafin_rendic.remission_amendment_request(deadline)
|
||||||
|
WHERE status = 'AWAITING';
|
||||||
|
""",
|
||||||
|
# 2026-04-20 v4: tabella config reminder data-driven, speculare al BE
|
||||||
|
# (expiration_config type='AMENDMENT' interval_days=N). Permette righe multiple
|
||||||
|
# per triggerare reminder a N gg diversi dalla scadenza (es. 7gg + 2gg).
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS gepafin_rendic.remission_expiration_config (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
type varchar(50) NOT NULL,
|
||||||
|
interval_days integer NOT NULL CHECK (interval_days > 0),
|
||||||
|
is_deleted boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expiration_config_type
|
||||||
|
ON gepafin_rendic.remission_expiration_config(type)
|
||||||
|
WHERE is_deleted = false;
|
||||||
|
""",
|
||||||
|
# 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)
|
sha256 = Column(String(64), nullable=True)
|
||||||
uploaded_by = Column(Integer, 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
|
# Campi istruttoria
|
||||||
verification_status = Column(String(16), nullable=False, default="PENDING")
|
verification_status = Column(String(16), nullable=False, default="PENDING")
|
||||||
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
||||||
@@ -230,12 +235,47 @@ class RemissionAmendmentRequest(Base):
|
|||||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
closed_by = Column(Integer, nullable=True)
|
closed_by = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# soccorso v3: extended/document/PEC tracking
|
||||||
|
response_days = Column(Integer, nullable=True)
|
||||||
|
extended_days = Column(Integer, nullable=True)
|
||||||
|
extension_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
internal_note = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
amendment_document_path = Column(String(1024), nullable=True)
|
||||||
|
amendment_document_type = Column(String(128), nullable=True)
|
||||||
|
amendment_initial_document_path = Column(String(1024), nullable=True)
|
||||||
|
|
||||||
|
response_document_path = Column(String(1024), nullable=True)
|
||||||
|
response_document_type = Column(String(128), nullable=True)
|
||||||
|
|
||||||
|
# popolati dal BE via endpoint interni mark-pec-sent / mark-pec-failed
|
||||||
|
protocol_id = Column(String(128), nullable=True)
|
||||||
|
email_log_id = Column(Integer, nullable=True)
|
||||||
|
user_action_id = Column(Integer, nullable=True)
|
||||||
|
pec_sent_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
pec_failed_reason = Column(Text, nullable=True)
|
||||||
|
pec_retry_after = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
practice = relationship("RemissionPractice", back_populates="amendment_requests")
|
practice = relationship("RemissionPractice", back_populates="amendment_requests")
|
||||||
|
|
||||||
|
|
||||||
|
class RemissionExpirationConfig(Base):
|
||||||
|
"""Config data-driven per reminder scadenze amendment (speculare a BE Gepafin
|
||||||
|
expiration_config). Ogni riga con type='AMENDMENT' e interval_days=N triggera
|
||||||
|
un reminder esattamente N giorni prima della scadenza. Multipli row = multipli reminder."""
|
||||||
|
__tablename__ = "remission_expiration_config"
|
||||||
|
__table_args__ = ({"schema": "gepafin_rendic"},)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
type = Column(String(50), nullable=False)
|
||||||
|
interval_days = Column(Integer, nullable=False)
|
||||||
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class RemissionCustomCheckValue(Base):
|
class RemissionCustomCheckValue(Base):
|
||||||
"""Valore di un controllo custom configurato dallo schema del bando.
|
"""Valore di un controllo custom configurato dallo schema del bando.
|
||||||
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.
|
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ def assignments_overview(
|
|||||||
manager: AuthUser = Depends(_require_manager),
|
manager: AuthUser = Depends(_require_manager),
|
||||||
):
|
):
|
||||||
"""Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'."""
|
"""Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'."""
|
||||||
practices = db.query(RemissionPractice).filter(
|
# Vista manager: tutte le pratiche (incluso DRAFT in compilazione dal benef e
|
||||||
RemissionPractice.status.in_(["SUBMITTED", "UNDER_REVIEW", "AWAITING_AMENDMENT"])
|
# APPROVED/REJECTED chiuse) perche il capo istruttore deve vedere tutto per
|
||||||
).order_by(
|
# riassegnare, monitorare carico, verificare storici.
|
||||||
|
practices = db.query(RemissionPractice).order_by(
|
||||||
RemissionPractice.application_id,
|
RemissionPractice.application_id,
|
||||||
RemissionPractice.sequence_number
|
RemissionPractice.sequence_number
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
@@ -36,16 +36,16 @@ def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPr
|
|||||||
if not p:
|
if not p:
|
||||||
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
||||||
# Autorizzazione base: beneficiario owner o istruttore
|
# 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")
|
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")
|
raise HTTPException(status_code=403, detail="Ruolo non autorizzato")
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
|
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
|
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
|
||||||
if not user.is_beneficiary():
|
if not user.is_owner_role():
|
||||||
return False
|
return False
|
||||||
if practice.user_id != user.user_id:
|
if practice.user_id != user.user_id:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ from uuid import UUID
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..db import get_db
|
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."""
|
"""Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
|
||||||
if _is_instructor(user):
|
if _is_instructor(user):
|
||||||
return True
|
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 practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -44,14 +46,14 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
|
|||||||
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
|
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
if _is_instructor(user):
|
if _is_instructor(user):
|
||||||
return True
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
|
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file."""
|
"""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")
|
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||||
if user.is_superadmin():
|
if user.is_superadmin():
|
||||||
return True
|
return True
|
||||||
@@ -267,3 +269,97 @@ def delete_entity_file(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return ApiResponse(success=True, message="File eliminato")
|
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 uuid import UUID
|
||||||
from typing import List
|
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.orm import Session
|
||||||
from sqlalchemy import text, or_, and_
|
from sqlalchemy import text, or_, and_
|
||||||
|
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..auth import AuthUser, get_current_user
|
from ..auth import AuthUser, get_current_user
|
||||||
|
from ..storage import save_upload, FileTooLargeError, MimeNotAllowedError, StorageError
|
||||||
from ..models import RemissionPractice, RemissionAmendmentRequest
|
from ..models import RemissionPractice, RemissionAmendmentRequest
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit,
|
AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
|
||||||
ReviewApproveBody, ReviewRejectBody,
|
ReviewApproveBody, ReviewRejectBody,
|
||||||
InstructorQueueItem, PracticeOut, ApiResponse,
|
InstructorQueueItem, PracticeOut, ApiResponse,
|
||||||
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
|
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
|
||||||
@@ -88,8 +89,14 @@ def instructor_queue(db: Session = Depends(get_db), user: AuthUser = Depends(_re
|
|||||||
)
|
)
|
||||||
if not manager:
|
if not manager:
|
||||||
# solo: SUBMITTED non assegnate OR UNDER_REVIEW assegnate a me OR AWAITING_AMENDMENT assegnate a me
|
# 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_(
|
q = q.filter(or_(
|
||||||
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id.is_(None)),
|
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 == "UNDER_REVIEW", RemissionPractice.assigned_instructor_id == user.user_id),
|
||||||
and_(RemissionPractice.status == "AWAITING_AMENDMENT", 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 ==========
|
# ========== SOCCORSO ISTRUTTORIO ==========
|
||||||
|
|
||||||
|
DEFAULT_RESPONSE_DAYS = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _amendment_or_404(db: Session, practice_id: UUID, amendment_id: UUID) -> RemissionAmendmentRequest:
|
||||||
|
ar = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.id == amendment_id,
|
||||||
|
RemissionAmendmentRequest.practice_id == practice_id
|
||||||
|
).first()
|
||||||
|
if not ar:
|
||||||
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
||||||
|
return ar
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{practice_id}/amendment", response_model=ApiResponse)
|
@router.post("/{practice_id}/amendment", response_model=ApiResponse)
|
||||||
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
||||||
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
"""Crea una richiesta di soccorso istruttorio."""
|
"""Crea una richiesta di soccorso istruttorio in stato DRAFT.
|
||||||
|
|
||||||
|
La PEC parte solo quando l'istruttore chiama esplicitamente /send. Finche e DRAFT:
|
||||||
|
- l'istruttore puo modificare/eliminare
|
||||||
|
- la pratica resta UNDER_REVIEW (nessun impatto sul benef)
|
||||||
|
- nessuna notifica PEC
|
||||||
|
"""
|
||||||
p = _get_practice_or_404(db, practice_id)
|
p = _get_practice_or_404(db, practice_id)
|
||||||
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
|
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
@@ -200,11 +226,13 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
|||||||
if not body.request_text or len(body.request_text.strip()) < 10:
|
if not body.request_text or len(body.request_text.strip()) < 10:
|
||||||
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
||||||
|
|
||||||
# controllo: non ci deve essere già una amendment AWAITING aperta
|
# controllo: non ci deve essere gia una amendment non-CLOSED/non-EXPIRED aperta
|
||||||
open_ar = [a for a in p.amendment_requests if a.status == "AWAITING"]
|
open_ar = [a for a in p.amendment_requests
|
||||||
|
if a.status in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value,
|
||||||
|
AmendmentStatus.RESPONSE_RECEIVED.value)]
|
||||||
if open_ar:
|
if open_ar:
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
detail="C'è già una richiesta di soccorso aperta su questa pratica")
|
detail="C'e gia una richiesta di soccorso aperta su questa pratica")
|
||||||
|
|
||||||
ar = RemissionAmendmentRequest(
|
ar = RemissionAmendmentRequest(
|
||||||
practice_id=p.id,
|
practice_id=p.id,
|
||||||
@@ -212,37 +240,142 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
|||||||
request_text=body.request_text,
|
request_text=body.request_text,
|
||||||
deadline=body.deadline,
|
deadline=body.deadline,
|
||||||
scope=body.scope or {},
|
scope=body.scope or {},
|
||||||
status="AWAITING"
|
response_days=body.response_days if body.response_days is not None else DEFAULT_RESPONSE_DAYS,
|
||||||
|
internal_note=body.internal_note,
|
||||||
|
status=AmendmentStatus.DRAFT.value
|
||||||
)
|
)
|
||||||
db.add(ar)
|
db.add(ar)
|
||||||
|
# pratica resta UNDER_REVIEW in DRAFT (passa a AWAITING_AMENDMENT solo allo /send)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message="Bozza soccorso istruttorio creata",
|
||||||
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse)
|
||||||
|
def update_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentRequestUpdate,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Modifica una bozza di soccorso. Consentito solo in stato DRAFT.
|
||||||
|
Dopo invio (AWAITING) il contenuto PEC e immutabile; si puo solo chiudere o prorogare."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.DRAFT.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Modifica consentita solo in stato DRAFT (attuale: {ar.status})")
|
||||||
|
if body.request_text is not None:
|
||||||
|
if len(body.request_text.strip()) < 10:
|
||||||
|
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
||||||
|
ar.request_text = body.request_text
|
||||||
|
if body.deadline is not None:
|
||||||
|
ar.deadline = body.deadline
|
||||||
|
if body.scope is not None:
|
||||||
|
ar.scope = body.scope
|
||||||
|
if body.response_days is not None:
|
||||||
|
if body.response_days < 1 or body.response_days > 120:
|
||||||
|
raise HTTPException(status_code=422, detail="response_days deve essere 1-120")
|
||||||
|
ar.response_days = body.response_days
|
||||||
|
if body.internal_note is not None:
|
||||||
|
ar.internal_note = body.internal_note
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message="Bozza aggiornata",
|
||||||
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse)
|
||||||
|
def delete_amendment(practice_id: UUID, amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Elimina una bozza di soccorso. Consentito solo in stato DRAFT.
|
||||||
|
Una volta inviata (AWAITING) si puo solo chiudere o scadere."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.DRAFT.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Eliminazione consentita solo in stato DRAFT (attuale: {ar.status})")
|
||||||
|
db.delete(ar)
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="Bozza eliminata", data={"id": str(amendment_id)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/send", response_model=ApiResponse)
|
||||||
|
def send_amendment(practice_id: UUID, amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Invia il soccorso: DRAFT -> AWAITING.
|
||||||
|
|
||||||
|
Da questo momento il BE Gepafin (poller interno) vedra l'amendment come pending-pec
|
||||||
|
e si occupera di PEC/protocollo tenant-aware. La pratica passa a AWAITING_AMENDMENT
|
||||||
|
(benef puo modificare) e il benef ricevera notifica quando la PEC arriva davvero."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.DRAFT.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Send consentito solo in stato DRAFT (attuale: {ar.status})")
|
||||||
|
if not ar.request_text or len(ar.request_text.strip()) < 10:
|
||||||
|
raise HTTPException(status_code=422, detail="Testo richiesta troppo breve")
|
||||||
|
|
||||||
|
ar.status = AmendmentStatus.AWAITING.value
|
||||||
|
p = _get_practice_or_404(db, practice_id)
|
||||||
p.status = "AWAITING_AMENDMENT"
|
p.status = "AWAITING_AMENDMENT"
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ar)
|
db.refresh(ar)
|
||||||
return ApiResponse(message="Soccorso istruttorio avviato",
|
return ApiResponse(message="Soccorso inviato. In attesa di invio PEC da backend.",
|
||||||
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/extend", response_model=ApiResponse)
|
||||||
|
def extend_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentExtend,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Proroga la deadline di un soccorso AWAITING.
|
||||||
|
Somma extended_days alla deadline attuale. Traccia extension_date."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.AWAITING.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Proroga consentita solo in stato AWAITING (attuale: {ar.status})")
|
||||||
|
from datetime import timedelta
|
||||||
|
ar.deadline = ar.deadline + timedelta(days=body.extended_days)
|
||||||
|
ar.extended_days = (ar.extended_days or 0) + body.extended_days
|
||||||
|
ar.extension_date = datetime.now(timezone.utc)
|
||||||
|
if body.motivation:
|
||||||
|
ar.internal_note = ((ar.internal_note or "") + f"\n[Proroga {body.extended_days}gg {datetime.now(timezone.utc):%Y-%m-%d}]: {body.motivation}").strip()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message=f"Deadline prorogata di {body.extended_days} giorni",
|
||||||
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/reminder", response_model=ApiResponse)
|
||||||
|
def send_reminder(practice_id: UUID, amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Reminder manuale on-demand dell'istruttore al benef.
|
||||||
|
Accoda un secondo invio PEC (stesso contenuto richiesta) via flag interno.
|
||||||
|
Il BE vedra l'amendment come pending-pec=reminder e inviera email di reminder."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.AWAITING.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail="Reminder consentito solo su soccorsi AWAITING")
|
||||||
|
# flag minimo: segnala via campo separato. Per ora usiamo pec_retry_after come "serve reminder"
|
||||||
|
# (il BE poller distinguera pec_sent_at IS NULL vs pec_sent_at IS NOT NULL + pec_retry_after)
|
||||||
|
ar.pec_retry_after = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="Reminder accodato. Il backend invierà l'email di sollecito.",
|
||||||
|
data={"amendment_id": str(amendment_id), "queued_at": ar.pec_retry_after.isoformat()})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
|
@router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
|
||||||
def close_amendment(practice_id: UUID, amendment_id: UUID,
|
def close_amendment(practice_id: UUID, amendment_id: UUID,
|
||||||
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
"""Istruttore chiude il soccorso (dopo aver visto la risposta beneficiario).
|
"""Istruttore chiude il soccorso. La pratica torna in UNDER_REVIEW
|
||||||
La pratica torna in UNDER_REVIEW."""
|
se non ci sono altri amendment aperti su di essa."""
|
||||||
ar = db.query(RemissionAmendmentRequest).filter(
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
RemissionAmendmentRequest.id == amendment_id,
|
if ar.status == AmendmentStatus.CLOSED.value:
|
||||||
RemissionAmendmentRequest.practice_id == practice_id
|
raise HTTPException(status_code=409, detail="Amendment gia chiusa")
|
||||||
).first()
|
|
||||||
if not ar:
|
|
||||||
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
|
||||||
if ar.status == "CLOSED":
|
|
||||||
raise HTTPException(status_code=409, detail="Amendment già chiusa")
|
|
||||||
|
|
||||||
ar.status = "CLOSED"
|
ar.status = AmendmentStatus.CLOSED.value
|
||||||
ar.closed_at = datetime.now(timezone.utc)
|
ar.closed_at = datetime.now(timezone.utc)
|
||||||
ar.closed_by = user.user_id
|
ar.closed_by = user.user_id
|
||||||
|
|
||||||
# rimetto la pratica in UNDER_REVIEW se non ci sono altre amendment aperte
|
|
||||||
p = _get_practice_or_404(db, practice_id)
|
p = _get_practice_or_404(db, practice_id)
|
||||||
others_open = [a for a in p.amendment_requests if a.id != ar.id and a.status == "AWAITING"]
|
others_open = [a for a in p.amendment_requests
|
||||||
|
if a.id != ar.id and a.status in (AmendmentStatus.DRAFT.value,
|
||||||
|
AmendmentStatus.AWAITING.value,
|
||||||
|
AmendmentStatus.RESPONSE_RECEIVED.value)]
|
||||||
if not others_open:
|
if not others_open:
|
||||||
p.status = "UNDER_REVIEW"
|
p.status = "UNDER_REVIEW"
|
||||||
|
|
||||||
@@ -252,6 +385,59 @@ def close_amendment(practice_id: UUID, amendment_id: UUID,
|
|||||||
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/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
|
# Endpoint beneficiario: visualizza amendments sulla sua pratica + risponde
|
||||||
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
|
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
|
||||||
def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
|
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)):
|
user: AuthUser = Depends(get_current_user)):
|
||||||
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
|
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
|
||||||
p = _get_practice_or_404(db, practice_id)
|
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")
|
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
||||||
|
|
||||||
ar = db.query(RemissionAmendmentRequest).filter(
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
RemissionAmendmentRequest.id == amendment_id,
|
|
||||||
RemissionAmendmentRequest.practice_id == practice_id
|
|
||||||
).first()
|
|
||||||
if not ar:
|
|
||||||
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
|
||||||
if ar.status != "AWAITING":
|
if ar.status != "AWAITING":
|
||||||
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")
|
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")
|
||||||
|
|
||||||
@@ -429,3 +610,44 @@ def set_instructor_final_notes(practice_id: UUID, body: InstructorFinalNotesBody
|
|||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
return ApiResponse(message="Verbale aggiornato",
|
return ApiResponse(message="Verbale aggiornato",
|
||||||
data=PracticeOut.model_validate(p).model_dump(mode="json"))
|
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")
|
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
||||||
|
|
||||||
# Solo il beneficiario owner o un superadmin può accedere
|
# 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")
|
raise HTTPException(status_code=403, detail="Accesso negato a questa pratica")
|
||||||
|
|
||||||
return practice
|
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"
|
"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)
|
# Check 6: importo range (cap erogato)
|
||||||
amt_range = rules.get("amount_range", {})
|
amt_range = rules.get("amount_range", {})
|
||||||
min_e = Decimal(str(amt_range.get("min", 0)))
|
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":
|
if app_row["status"] != "CONTRACT_SIGNED":
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
|
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")
|
raise HTTPException(status_code=403, detail="Application non di tua proprieta")
|
||||||
|
|
||||||
# Schema del bando
|
# Schema del bando
|
||||||
@@ -422,7 +446,7 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
|||||||
if not schema:
|
if not schema:
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
detail="Nessuno schema di rendicontazione configurato per questo bando.")
|
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,
|
raise HTTPException(status_code=409,
|
||||||
detail="Lo schema di rendicontazione non e ancora stato pubblicato.")
|
detail="Lo schema di rendicontazione non e ancora stato pubblicato.")
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,153 @@
|
|||||||
"""
|
"""
|
||||||
Endpoint gestione schema rendicontazione per bando.
|
Endpoint gestione schema rendicontazione per bando.
|
||||||
|
v2.1 (2026-04-18): picker multi-sorgente (blank, template predefinito, clone da altro bando).
|
||||||
"""
|
"""
|
||||||
import copy
|
import copy
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..auth import AuthUser, get_current_user, require_superadmin
|
from ..auth import AuthUser, get_current_user, require_superadmin
|
||||||
from ..models import CallRemissionSchema
|
from ..models import CallRemissionSchema
|
||||||
from ..schemas import RemissionSchemaOut, RemissionSchemaCreate, RemissionSchemaUpdate, ApiResponse
|
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"])
|
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)
|
@router.get("/{call_id}", response_model=ApiResponse)
|
||||||
def get_schema(call_id: int, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
|
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."""
|
"""Legge lo schema di rendicontazione per un bando. 404 se non esiste."""
|
||||||
@@ -33,12 +167,12 @@ def create_schema(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
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()
|
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
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(
|
schema = CallRemissionSchema(
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
@@ -62,7 +196,7 @@ def update_schema(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
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()
|
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
if not schema:
|
if not schema:
|
||||||
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}")
|
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),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
user: AuthUser = Depends(require_superadmin),
|
||||||
):
|
):
|
||||||
"""Inizializza schema per un bando usando il template RE-START. Fallisce se esiste già."""
|
"""DEPRECATO in 2.1 — alias di /initialize con source=template&template_id=restart.
|
||||||
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
Mantenuto per backward compatibility del FE."""
|
||||||
if existing:
|
body = SchemaInitializeRequest(source="template", template_id="restart")
|
||||||
raise HTTPException(
|
return initialize_schema(call_id=call_id, body=body, db=db, user=user)
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{call_id}/publish", response_model=ApiResponse)
|
@router.post("/{call_id}/publish", response_model=ApiResponse)
|
||||||
@@ -115,7 +233,7 @@ def publish_schema(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
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
|
from datetime import datetime, timezone
|
||||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
if not schema:
|
if not schema:
|
||||||
@@ -123,7 +241,7 @@ def publish_schema(
|
|||||||
if schema.status == "PUBLISHED":
|
if schema.status == "PUBLISHED":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail="Schema già pubblicato.",
|
detail="Schema gia pubblicato.",
|
||||||
)
|
)
|
||||||
schema.status = "PUBLISHED"
|
schema.status = "PUBLISHED"
|
||||||
schema.published_at = datetime.now(timezone.utc)
|
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}")
|
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)):
|
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)
|
return ApiResponse(data=RESTART_TEMPLATE)
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..auth import AuthUser, get_current_user
|
from ..auth import AuthUser, get_current_user
|
||||||
from ..models import RemissionPractice
|
from ..models import RemissionPractice, RemissionCustomCheckValue
|
||||||
from .practices import _compute_gate_check
|
from .practices import _compute_gate_check
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"])
|
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"])
|
||||||
|
|
||||||
|
|
||||||
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates_jinja"
|
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 ----------
|
# ---------- Jinja env & filters ----------
|
||||||
@@ -177,6 +180,50 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
|
|||||||
"""), {"uid": user.user_id}).scalar()
|
"""), {"uid": user.user_id}).scalar()
|
||||||
instructor_name = row
|
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 {
|
return {
|
||||||
"practice": practice,
|
"practice": practice,
|
||||||
"totals": totals,
|
"totals": totals,
|
||||||
@@ -196,6 +243,11 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
|
|||||||
"company": company,
|
"company": company,
|
||||||
"instructor_name": instructor_name,
|
"instructor_name": instructor_name,
|
||||||
"generated_at": datetime.now().strftime("%d/%m/%Y"),
|
"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)
|
practice, html = _render_html(db, practice_id, user)
|
||||||
pdf_bytes = WeasyHTML(string=html).write_pdf()
|
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(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
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 datetime import datetime, date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ class DocumentUpsert(BaseModel):
|
|||||||
uploaded_at: Optional[datetime] = None
|
uploaded_at: Optional[datetime] = None
|
||||||
expires_at: Optional[date] = None
|
expires_at: Optional[date] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
source_company_document_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class DocumentOut(BaseModel):
|
class DocumentOut(BaseModel):
|
||||||
@@ -127,6 +129,7 @@ class DocumentOut(BaseModel):
|
|||||||
uploaded_at: Optional[datetime] = None
|
uploaded_at: Optional[datetime] = None
|
||||||
expires_at: Optional[date] = None
|
expires_at: Optional[date] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
source_company_document_id: Optional[int] = None
|
||||||
# istruttoria
|
# istruttoria
|
||||||
verification_status: str = "PENDING"
|
verification_status: str = "PENDING"
|
||||||
verification_notes: Optional[str] = None
|
verification_notes: Optional[str] = None
|
||||||
@@ -209,10 +212,36 @@ class GateCheckResult(BaseModel):
|
|||||||
|
|
||||||
# ====================== Istruttoria ======================
|
# ====================== Istruttoria ======================
|
||||||
|
|
||||||
|
# Stati formali amendment (speculare BE Gepafin ApplicationAmendmentRequestEnum, ridotti)
|
||||||
|
class AmendmentStatus(str, Enum):
|
||||||
|
DRAFT = "DRAFT" # istruttore prepara, PEC non ancora partita
|
||||||
|
AWAITING = "AWAITING" # PEC inviata, attendo risposta benef
|
||||||
|
RESPONSE_RECEIVED = "RESPONSE_RECEIVED" # benef ha risposto, istruttore deve valutare
|
||||||
|
EXPIRED = "EXPIRED" # deadline passata senza risposta (scheduler)
|
||||||
|
CLOSED = "CLOSED" # istruttore chiude dopo response o comunque
|
||||||
|
|
||||||
|
|
||||||
class AmendmentRequestCreate(BaseModel):
|
class AmendmentRequestCreate(BaseModel):
|
||||||
request_text: str
|
request_text: str
|
||||||
deadline: date
|
deadline: date
|
||||||
scope: Optional[dict] = None
|
scope: Optional[dict] = None
|
||||||
|
response_days: Optional[int] = None # pre-compilato default 15gg, variabile per istruttore
|
||||||
|
internal_note: Optional[str] = None # visibile solo istruttore
|
||||||
|
|
||||||
|
|
||||||
|
class AmendmentRequestUpdate(BaseModel):
|
||||||
|
"""Modifica amendment solo in stato DRAFT."""
|
||||||
|
request_text: Optional[str] = None
|
||||||
|
deadline: Optional[date] = None
|
||||||
|
scope: Optional[dict] = None
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
internal_note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AmendmentExtend(BaseModel):
|
||||||
|
"""Prolunga la deadline di un amendment AWAITING."""
|
||||||
|
extended_days: int = Field(..., gt=0, le=60)
|
||||||
|
motivation: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AmendmentResponseSubmit(BaseModel):
|
class AmendmentResponseSubmit(BaseModel):
|
||||||
@@ -233,9 +262,63 @@ class AmendmentRequestOut(BaseModel):
|
|||||||
closed_by: Optional[int] = None
|
closed_by: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
# soccorso v3
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
extended_days: Optional[int] = None
|
||||||
|
extension_date: Optional[datetime] = None
|
||||||
|
internal_note: Optional[str] = None
|
||||||
|
amendment_document_path: Optional[str] = None
|
||||||
|
amendment_document_type: Optional[str] = None
|
||||||
|
response_document_path: Optional[str] = None
|
||||||
|
response_document_type: Optional[str] = None
|
||||||
|
protocol_id: Optional[str] = None
|
||||||
|
pec_sent_at: Optional[datetime] = None
|
||||||
|
pec_failed_reason: Optional[str] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== schemas per endpoint interni chiamati dal BE Gepafin =====
|
||||||
|
|
||||||
|
class AmendmentPendingPecOut(BaseModel):
|
||||||
|
"""Lista amendment che il BE deve processare per invio PEC."""
|
||||||
|
id: UUID
|
||||||
|
practice_id: UUID
|
||||||
|
application_id: int
|
||||||
|
request_text: str
|
||||||
|
deadline: date
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
amendment_document_path: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AmendmentPecDetail(BaseModel):
|
||||||
|
"""Dettaglio richiesto dal BE per comporre PEC."""
|
||||||
|
id: UUID
|
||||||
|
practice_id: UUID
|
||||||
|
application_id: int
|
||||||
|
company_id: int
|
||||||
|
call_id: int
|
||||||
|
sequence_number: int
|
||||||
|
period_label: Optional[str] = None
|
||||||
|
request_text: str
|
||||||
|
deadline: date
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
amendment_document_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MarkPecSent(BaseModel):
|
||||||
|
protocol_id: str
|
||||||
|
email_log_id: Optional[int] = None
|
||||||
|
user_action_id: Optional[int] = None
|
||||||
|
pec_sent_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MarkPecFailed(BaseModel):
|
||||||
|
reason: str
|
||||||
|
retry_after: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class ReviewRejectBody(BaseModel):
|
class ReviewRejectBody(BaseModel):
|
||||||
rejection_reason: str
|
rejection_reason: str
|
||||||
|
|
||||||
|
|||||||
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(
|
def save_upload(
|
||||||
application_id: int,
|
application_id: int,
|
||||||
entity_type: str, # invoice | ula | document
|
entity_type: str, # invoice | ula | document | amendment-instructor-doc | amendment-response-doc
|
||||||
entity_id: UUID,
|
entity_id: UUID,
|
||||||
file_obj: BinaryIO,
|
file_obj: BinaryIO,
|
||||||
original_filename: str,
|
original_filename: str,
|
||||||
@@ -62,7 +62,7 @@ def save_upload(
|
|||||||
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
|
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
|
||||||
- dimensione <= MAX_SIZE_BYTES
|
- 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}")
|
raise StorageError(f"entity_type non valido: {entity_type}")
|
||||||
|
|
||||||
safe_name = _safe_filename(original_filename)
|
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
|
sec["enabled"] = True
|
||||||
changed = True
|
changed = True
|
||||||
return schema_json
|
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;
|
padding-bottom: 10pt;
|
||||||
margin-bottom: 14pt;
|
margin-bottom: 14pt;
|
||||||
}
|
}
|
||||||
.hdr__logo {
|
.hdr__logo-img {
|
||||||
font-size: 22pt; font-weight: 900; color: #1a365d; letter-spacing: 1pt;
|
height: 38pt; width: auto;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.hdr__subtitle {
|
.hdr__subtitle {
|
||||||
@@ -182,10 +182,11 @@
|
|||||||
<div class="hdr__right">
|
<div class="hdr__right">
|
||||||
<div><strong>Verbale di istruttoria</strong></div>
|
<div><strong>Verbale di istruttoria</strong></div>
|
||||||
<div>Rendicontazione bando</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>
|
||||||
<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 class="hdr__subtitle">Finanziaria regionale dell'Umbria</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,6 +238,15 @@
|
|||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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="row">
|
||||||
<div class="cell label">Data presentazione</div>
|
<div class="cell label">Data presentazione</div>
|
||||||
<div class="cell val">{{ practice.submitted_at|datetimefmt if practice.submitted_at else '—' }}</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>
|
<p class="text-secondary">Nessun documento richiesto dallo schema del bando.</p>
|
||||||
{% endif %}
|
{% 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 ============ #}
|
{# ============ SOCCORSI ============ #}
|
||||||
{% if amendments %}
|
{% if amendments %}
|
||||||
<h2>Soccorso istruttorio</h2>
|
<h2>Soccorso istruttorio</h2>
|
||||||
@@ -406,43 +455,94 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# ============ TOTALI ============ #}
|
{# ============ STORICO TRANCHES PRECEDENTI ============ #}
|
||||||
<h2>Riepilogo finanziario</h2>
|
{% 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="totals-summary">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="cell">
|
<div class="cell" style="width:50%">
|
||||||
<div class="lbl">Totale dichiarato</div>
|
<div class="lbl">(1) Importo massimo ammissibile (cap globale)</div>
|
||||||
<div class="val">{{ totals.grand_total_declared|euro }}</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>
|
||||||
<div class="cell">
|
<div class="cell" style="width:50%">
|
||||||
<div class="lbl">Totale ammesso</div>
|
<div class="lbl">(4) Importo finanziamento erogato</div>
|
||||||
<div class="val">{{ totals.grand_total_verified|euro }}</div>
|
<div class="val">{{ totals.amount_erogato|euro }}</div>
|
||||||
</div>
|
<div class="lbl" style="margin-top:6pt">tranches complessive</div>
|
||||||
<div class="cell">
|
<div style="font-size:10pt">{{ totals.tranches_count }} / {{ totals.tranches_max }}</div>
|
||||||
<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>
|
||||||
</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>
|
|
||||||
{% endif %}
|
|
||||||
</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 ============ #}
|
{# ============ CHECKLIST + NOTE ============ #}
|
||||||
{% set checklist = practice.instructor_checklist or {} %}
|
{% set checklist = practice.instructor_checklist or {} %}
|
||||||
{% if checklist %}
|
{% if checklist %}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ python-multipart==0.0.9
|
|||||||
weasyprint==61.2
|
weasyprint==61.2
|
||||||
pydyf==0.10.0
|
pydyf==0.10.0
|
||||||
jinja2==3.1.3
|
jinja2==3.1.3
|
||||||
|
APScheduler==3.10.4
|
||||||
|
|||||||
Reference in New Issue
Block a user