feat(schemas): picker 3-card blank/template/clone per inizializzazione schema

Sostituisce il vecchio flusso 'initialize-restart' (unico template hardcoded)
con un picker unificato che offre 3 sorgenti di inizializzazione:

1. BLANK — schema scheletro v2 con sezioni vuote (categorie, documenti, custom_checks)
   da popolare; ULA disabilitata di default; max_tranches=1.
2. TEMPLATE — parte da template predefinito nel registry TEMPLATES di app/templates.py.
   Oggi: 'blank' + 'restart'. Struttura estendibile per nuovi template bandi.
3. CLONE — copia schema_json di un altro bando esistente (DRAFT o PUBLISHED).
   Useful per bandi 'sorella'. upgrade_schema_to_v2 applicato on-copy per schemi v1 legacy.

app/templates.py: aggiunto BLANK_TEMPLATE, registry TEMPLATES, helper list_templates()
e get_template(id).

app/routers/schemas.py: riscritto con 3 nuovi endpoint:
- GET /templates  -> lista metadati template disponibili
- GET /templates/{id}  -> preview schema completo
- GET /clonable-calls  -> bandi con schema (per dropdown clone)
- POST /{call_id}/initialize body {source, template_id?, source_call_id?}  -> unificato
Endpoint /initialize-restart mantenuto come alias di /initialize con template=restart
per backward compat del vecchio FE.

Testato E2E via curl: blank OK, template restart OK, clone da call 1 OK, errori
(source invalido/template_id inesistente/clone senza source_call_id/schema gia esistente/
bando inesistente) tutti gestiti con HTTP corretto.
This commit is contained in:
BFLOWS
2026-04-18 18:51:42 +02:00
parent 3021792c31
commit aeab399afa
2 changed files with 272 additions and 28 deletions

View File

@@ -1,19 +1,153 @@
"""
Endpoint gestione schema rendicontazione per bando.
v2.1 (2026-04-18): picker multi-sorgente (blank, template predefinito, clone da altro bando).
"""
import copy
from typing import Optional
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from ..db import get_db
from ..auth import AuthUser, get_current_user, require_superadmin
from ..models import CallRemissionSchema
from ..schemas import RemissionSchemaOut, RemissionSchemaCreate, RemissionSchemaUpdate, ApiResponse
from ..templates import RESTART_TEMPLATE
from ..templates import (
RESTART_TEMPLATE,
TEMPLATES,
list_templates,
get_template,
upgrade_schema_to_v2,
)
router = APIRouter(prefix="/api/rendicontazione-schemas", tags=["rendicontazione-schemas"])
# =========================================================================
# PICKER: template predefiniti + clonable calls
# =========================================================================
@router.get("/templates", response_model=ApiResponse)
def get_available_templates(user: AuthUser = Depends(require_superadmin)):
"""Elenca i template predefiniti disponibili (blank, restart, ...).
Ritorna solo metadati (template_id, label, description) — lo schema completo e con /templates/{id}."""
return ApiResponse(data={"templates": list_templates()})
@router.get("/templates/{template_id}", response_model=ApiResponse)
def get_template_preview(template_id: str, user: AuthUser = Depends(require_superadmin)):
"""Preview dello schema completo di un template. Non crea nulla."""
schema = get_template(template_id)
if schema is None:
raise HTTPException(status_code=404, detail=f"Template '{template_id}' non trovato")
return ApiResponse(data=schema)
@router.get("/clonable-calls", response_model=ApiResponse)
def list_clonable_calls(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Elenca i bandi che hanno gia uno schema di rendicontazione (DRAFT o PUBLISHED),
disponibili come sorgenti di clonazione. Esclude il bando ancora non deciso dal context."""
rows = db.execute(text("""
SELECT c.id AS call_id, c.name, c.status AS call_status,
s.status AS schema_status, s.created_at AS schema_created_at
FROM gepafin_rendic.call_remission_schema s
JOIN gepafin_schema.call c ON c.id = s.call_id AND c.is_deleted = false
ORDER BY s.created_at DESC
""")).mappings().all()
return ApiResponse(data={"calls": [dict(r) for r in rows]})
class SchemaInitializeRequest(BaseModel):
source: str = Field(..., description="blank | template | clone")
template_id: Optional[str] = Field(None, description="se source=template")
source_call_id: Optional[int] = Field(None, description="se source=clone")
@router.post("/{call_id}/initialize", response_model=ApiResponse)
def initialize_schema(
call_id: int,
body: SchemaInitializeRequest,
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Inizializza uno schema di rendicontazione per call_id in modalita:
- blank: schema vuoto con solo scheletro sezioni
- template: parte da template predefinito (body.template_id)
- clone: copia schema_json di un altro bando (body.source_call_id)
Lo schema risultante e sempre DRAFT. Fallisce 409 se esiste gia.
"""
# Target vuoto
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per modificarlo.",
)
# Verifica che il bando target esista
target = db.execute(text("""
SELECT id, name FROM gepafin_schema.call
WHERE id = :cid AND is_deleted = false
"""), {"cid": call_id}).mappings().first()
if not target:
raise HTTPException(status_code=404, detail=f"Bando call_id={call_id} non trovato")
# Risolvi schema_json secondo source
source = (body.source or "").lower()
if source == "blank":
schema_json = get_template("blank")
msg = f"Schema vuoto inizializzato per call_id={call_id}"
elif source == "template":
tpl_id = body.template_id or ""
schema_json = get_template(tpl_id)
if schema_json is None:
raise HTTPException(status_code=422,
detail=f"template_id '{tpl_id}' non valido. Usa GET /templates per l'elenco.")
msg = f"Schema inizializzato da template '{tpl_id}' per call_id={call_id}"
elif source == "clone":
src_id = body.source_call_id
if not src_id:
raise HTTPException(status_code=422, detail="source_call_id richiesto per source=clone")
src_schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == src_id).first()
if not src_schema:
raise HTTPException(status_code=404,
detail=f"Schema sorgente non trovato per source_call_id={src_id}")
schema_json = copy.deepcopy(src_schema.schema_json)
# assicura upgrade a v2 se il sorgente era v1
schema_json = upgrade_schema_to_v2(schema_json)
msg = f"Schema clonato da call_id={src_id} per call_id={call_id}"
else:
raise HTTPException(status_code=422,
detail="source deve essere 'blank', 'template' o 'clone'")
# Persisti nuovo schema DRAFT
schema = CallRemissionSchema(
call_id=call_id,
schema_json=schema_json,
status="DRAFT",
created_by=user.user_id,
)
db.add(schema)
db.commit()
db.refresh(schema)
return ApiResponse(
message=msg,
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
)
# =========================================================================
# Endpoint esistenti (CRUD, publish, delete)
# =========================================================================
@router.get("/{call_id}", response_model=ApiResponse)
def get_schema(call_id: int, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
"""Legge lo schema di rendicontazione per un bando. 404 se non esiste."""
@@ -33,12 +167,12 @@ def create_schema(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste già."""
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste gia."""
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
)
schema = CallRemissionSchema(
call_id=call_id,
@@ -62,7 +196,7 @@ def update_schema(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Aggiorna schema esistente. Blocca modifiche se già PUBLISHED."""
"""Aggiorna schema esistente. Blocca modifiche se gia PUBLISHED."""
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if not schema:
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}")
@@ -87,26 +221,10 @@ def initialize_restart(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Inizializza schema per un bando usando il template RE-START. Fallisce se esiste già."""
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per modificarlo.",
)
schema = CallRemissionSchema(
call_id=call_id,
schema_json=copy.deepcopy(RESTART_TEMPLATE),
status="DRAFT",
created_by=user.user_id,
)
db.add(schema)
db.commit()
db.refresh(schema)
return ApiResponse(
message=f"Schema RE-START inizializzato per call_id={call_id}",
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
)
"""DEPRECATO in 2.1 — alias di /initialize con source=template&template_id=restart.
Mantenuto per backward compatibility del FE."""
body = SchemaInitializeRequest(source="template", template_id="restart")
return initialize_schema(call_id=call_id, body=body, db=db, user=user)
@router.post("/{call_id}/publish", response_model=ApiResponse)
@@ -115,7 +233,7 @@ def publish_schema(
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non è più editabile."""
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non e piu editabile."""
from datetime import datetime, timezone
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if not schema:
@@ -123,7 +241,7 @@ def publish_schema(
if schema.status == "PUBLISHED":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Schema già pubblicato.",
detail="Schema gia pubblicato.",
)
schema.status = "PUBLISHED"
schema.published_at = datetime.now(timezone.utc)
@@ -156,7 +274,7 @@ def delete_schema(
return ApiResponse(message=f"Schema cancellato per call_id={call_id}")
@router.get("/templates/restart", response_model=ApiResponse)
@router.get("/templates-preview/restart", response_model=ApiResponse)
def get_restart_template(user: AuthUser = Depends(require_superadmin)):
"""Restituisce il template RE-START senza persisterlo. Utile per preview."""
"""DEPRECATO: usa GET /templates/restart invece. Mantenuto per backward compat."""
return ApiResponse(data=RESTART_TEMPLATE)

View File

@@ -176,3 +176,129 @@ def upgrade_schema_to_v2(schema_json: dict) -> dict:
sec["enabled"] = True
changed = True
return schema_json
# =========================================================================
# BLANK_TEMPLATE — scheletro minimo v2, solo sezioni vuote da popolare
# =========================================================================
BLANK_TEMPLATE = {
"version": "2.0",
"schema_version": 2,
"template_id": "BLANK_V2",
"template_label": "Nuovo schema vuoto",
"sections": [
{
"type": "static_fields",
"id": "general",
"label": "Dati generali",
"description": "Regime IVA, dati base del beneficiario, periodo di ammissibilita delle spese.",
"fields": [
{
"id": "period_start_date",
"type": "date",
"label": "Periodo ammissibilita — Data inizio",
"description": "Data minima di emissione/pagamento fatture ammissibili.",
"editable_by": "superadmin",
"required": True,
},
{
"id": "period_end_date",
"type": "date",
"label": "Periodo ammissibilita — Data fine",
"description": "Data massima di emissione/pagamento fatture ammissibili.",
"editable_by": "superadmin",
"required": True,
},
{
"id": "iva_regime",
"type": "select",
"label": "Regime IVA",
"required": True,
"options": [
{"value": "ORDINARIO", "label": "Ordinario — IVA non ammissibile"},
{"value": "FORFETTARIO", "label": "Forfettario — IVA ammissibile"},
{"value": "ESENTE", "label": "Esente"},
],
"help": "Il regime IVA determina se l'IVA delle fatture e rendicontabile.",
},
],
},
{
"type": "invoice_table",
"id": "invoices",
"label": "Fatture ammissibili",
"description": "Categorie di spesa da configurare. Aggiungi almeno una categoria prima di pubblicare.",
"categories": [],
},
{
"type": "ula_block",
"id": "ula",
"label": "Incremento occupazione (ULA)",
"description": "Dipendenti su cui calcolare l'incremento ULA. Disattiva la sezione se il bando non lo richiede.",
"enabled": False,
"threshold": 1.0,
"fields": [],
},
{
"type": "documents_required",
"id": "docs",
"label": "Documenti richiesti",
"description": "Documenti che il beneficiario deve allegare alla rendicontazione. Aggiungi almeno i documenti obbligatori.",
"items": [],
},
],
"custom_checks": [],
"gate_rules": {
"invoices_min_count": 1,
"amount_range": {"min": 0, "max": 100000},
"cap_pct_erogato": 0.5,
"cap_absolute": 100000,
"amount_basis": "imponibile_only_ordinario",
"period_start_rule": "erogato_date",
"period_end": None,
"require_at_least_one_invoice_per_nonzero_category": True,
"require_ula_above_threshold": False,
"require_all_documents_resolved": True,
"max_tranches": 1,
},
}
# =========================================================================
# TEMPLATES registry — esteso in futuro con nuovi bandi
# =========================================================================
TEMPLATES = {
"blank": {
"template_id": "blank",
"label": "Nuovo schema (da zero)",
"description": "Scheletro minimo: sezioni vuote (categorie, documenti, controlli) da popolare. Usa questo quando il bando e nuovo e non somiglia a bandi precedenti.",
"schema": BLANK_TEMPLATE,
},
"restart": {
"template_id": "restart",
"label": "RE-START (fondo prestiti con remissione del debito)",
"description": "Template del bando RE-START: 3 categorie B1/B2/B3 (tecnologie, ULA, formazione), sezione ULA attiva con soglia 1.0, 4 documenti standard, max 2 tranches.",
"schema": RESTART_TEMPLATE,
},
}
def list_templates():
"""Restituisce i template disponibili (senza lo schema completo, solo metadati)."""
return [
{
"template_id": t["template_id"],
"label": t["label"],
"description": t["description"],
}
for t in TEMPLATES.values()
]
def get_template(template_id: str):
"""Restituisce uno schema template pronto per l'uso (deep copy)."""
import copy
t = TEMPLATES.get(template_id)
if not t:
return None
return copy.deepcopy(t["schema"])