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:
@@ -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)
|
||||||
|
|||||||
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user