From aeab399afa161d45198a2ff52be7bb76fd70ab4a Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Sat, 18 Apr 2026 18:51:42 +0200 Subject: [PATCH] feat(schemas): picker 3-card blank/template/clone per inizializzazione schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/routers/schemas.py | 174 ++++++++++++++++++++++++++++++++++------- app/templates.py | 126 +++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 28 deletions(-) diff --git a/app/routers/schemas.py b/app/routers/schemas.py index 1a3f726..5a6f152 100644 --- a/app/routers/schemas.py +++ b/app/routers/schemas.py @@ -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) diff --git a/app/templates.py b/app/templates.py index daa9e59..610ee82 100644 --- a/app/templates.py +++ b/app/templates.py @@ -176,3 +176,129 @@ def upgrade_schema_to_v2(schema_json: dict) -> dict: sec["enabled"] = True changed = True return schema_json + + +# ========================================================================= +# BLANK_TEMPLATE — scheletro minimo v2, solo sezioni vuote da popolare +# ========================================================================= +BLANK_TEMPLATE = { + "version": "2.0", + "schema_version": 2, + "template_id": "BLANK_V2", + "template_label": "Nuovo schema vuoto", + "sections": [ + { + "type": "static_fields", + "id": "general", + "label": "Dati generali", + "description": "Regime IVA, dati base del beneficiario, periodo di ammissibilita delle spese.", + "fields": [ + { + "id": "period_start_date", + "type": "date", + "label": "Periodo ammissibilita — Data inizio", + "description": "Data minima di emissione/pagamento fatture ammissibili.", + "editable_by": "superadmin", + "required": True, + }, + { + "id": "period_end_date", + "type": "date", + "label": "Periodo ammissibilita — Data fine", + "description": "Data massima di emissione/pagamento fatture ammissibili.", + "editable_by": "superadmin", + "required": True, + }, + { + "id": "iva_regime", + "type": "select", + "label": "Regime IVA", + "required": True, + "options": [ + {"value": "ORDINARIO", "label": "Ordinario — IVA non ammissibile"}, + {"value": "FORFETTARIO", "label": "Forfettario — IVA ammissibile"}, + {"value": "ESENTE", "label": "Esente"}, + ], + "help": "Il regime IVA determina se l'IVA delle fatture e rendicontabile.", + }, + ], + }, + { + "type": "invoice_table", + "id": "invoices", + "label": "Fatture ammissibili", + "description": "Categorie di spesa da configurare. Aggiungi almeno una categoria prima di pubblicare.", + "categories": [], + }, + { + "type": "ula_block", + "id": "ula", + "label": "Incremento occupazione (ULA)", + "description": "Dipendenti su cui calcolare l'incremento ULA. Disattiva la sezione se il bando non lo richiede.", + "enabled": False, + "threshold": 1.0, + "fields": [], + }, + { + "type": "documents_required", + "id": "docs", + "label": "Documenti richiesti", + "description": "Documenti che il beneficiario deve allegare alla rendicontazione. Aggiungi almeno i documenti obbligatori.", + "items": [], + }, + ], + "custom_checks": [], + "gate_rules": { + "invoices_min_count": 1, + "amount_range": {"min": 0, "max": 100000}, + "cap_pct_erogato": 0.5, + "cap_absolute": 100000, + "amount_basis": "imponibile_only_ordinario", + "period_start_rule": "erogato_date", + "period_end": None, + "require_at_least_one_invoice_per_nonzero_category": True, + "require_ula_above_threshold": False, + "require_all_documents_resolved": True, + "max_tranches": 1, + }, +} + + +# ========================================================================= +# TEMPLATES registry — esteso in futuro con nuovi bandi +# ========================================================================= +TEMPLATES = { + "blank": { + "template_id": "blank", + "label": "Nuovo schema (da zero)", + "description": "Scheletro minimo: sezioni vuote (categorie, documenti, controlli) da popolare. Usa questo quando il bando e nuovo e non somiglia a bandi precedenti.", + "schema": BLANK_TEMPLATE, + }, + "restart": { + "template_id": "restart", + "label": "RE-START (fondo prestiti con remissione del debito)", + "description": "Template del bando RE-START: 3 categorie B1/B2/B3 (tecnologie, ULA, formazione), sezione ULA attiva con soglia 1.0, 4 documenti standard, max 2 tranches.", + "schema": RESTART_TEMPLATE, + }, +} + + +def list_templates(): + """Restituisce i template disponibili (senza lo schema completo, solo metadati).""" + return [ + { + "template_id": t["template_id"], + "label": t["label"], + "description": t["description"], + } + for t in TEMPLATES.values() + ] + + +def get_template(template_id: str): + """Restituisce uno schema template pronto per l'uso (deep copy).""" + import copy + t = TEMPLATES.get(template_id) + if not t: + return None + return copy.deepcopy(t["schema"])