""" 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)