Files
BFLOWS aeab399afa 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.
2026-04-18 18:51:42 +02:00

281 lines
11 KiB
Python

"""
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,
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."""
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
if not schema:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Nessuno schema di rendicontazione per call_id={call_id}. Usa POST per crearlo.",
)
return ApiResponse(data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"))
@router.post("/{call_id}", response_model=ApiResponse)
def create_schema(
call_id: int,
body: RemissionSchemaCreate,
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""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 gia esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
)
schema = CallRemissionSchema(
call_id=call_id,
schema_json=body.schema_json,
status="DRAFT",
created_by=user.user_id,
)
db.add(schema)
db.commit()
db.refresh(schema)
return ApiResponse(
message=f"Schema creato per call_id={call_id}",
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
)
@router.put("/{call_id}", response_model=ApiResponse)
def update_schema(
call_id: int,
body: RemissionSchemaUpdate,
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""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}")
if schema.status == "PUBLISHED":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Schema PUBLISHED non modificabile. Crea una nuova versione.",
)
if body.schema_json is not None:
schema.schema_json = body.schema_json
db.commit()
db.refresh(schema)
return ApiResponse(
message=f"Schema aggiornato per call_id={call_id}",
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
)
@router.post("/{call_id}/initialize-restart", response_model=ApiResponse)
def initialize_restart(
call_id: int,
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""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)
def publish_schema(
call_id: int,
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""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:
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}")
if schema.status == "PUBLISHED":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Schema gia pubblicato.",
)
schema.status = "PUBLISHED"
schema.published_at = datetime.now(timezone.utc)
schema.published_by = user.user_id
db.commit()
db.refresh(schema)
return ApiResponse(
message=f"Schema pubblicato per call_id={call_id}",
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
)
@router.delete("/{call_id}", response_model=ApiResponse)
def delete_schema(
call_id: int,
db: Session = Depends(get_db),
user: AuthUser = Depends(require_superadmin),
):
"""Cancella schema. Consentito solo in DRAFT."""
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}")
if schema.status == "PUBLISHED":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Schema PUBLISHED non cancellabile.",
)
db.delete(schema)
db.commit()
return ApiResponse(message=f"Schema cancellato per call_id={call_id}")
@router.get("/templates-preview/restart", response_model=ApiResponse)
def get_restart_template(user: AuthUser = Depends(require_superadmin)):
"""DEPRECATO: usa GET /templates/restart invece. Mantenuto per backward compat."""
return ApiResponse(data=RESTART_TEMPLATE)