initial skeleton: FastAPI + SQLAlchemy + schema rendicontazione + template RE-START

This commit is contained in:
BFLOWS
2026-04-18 07:50:06 +02:00
commit 63fd2f66e6
14 changed files with 602 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.env
.venv/
venv/
*.log
.pytest_cache/
.idea/
.vscode/

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
ENV TZ=Europe/Rome PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY scripts/ ./scripts/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

0
app/__init__.py Normal file
View File

72
app/auth.py Normal file
View File

@@ -0,0 +1,72 @@
"""
JWT validation compatibile con GEPAFIN-BE.
Il BE Spring emette token HS512 con payload:
sub: "email:userId:hubId"
userId: int
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ...
exp: unix timestamp
loginAttemptId: int
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from .config import get_settings
settings = get_settings()
bearer_scheme = HTTPBearer(auto_error=False)
class AuthUser:
def __init__(self, user_id: int, email: str, role: str, hub_id: int):
self.user_id = user_id
self.email = email
self.role = role
self.hub_id = hub_id
def is_superadmin(self) -> bool:
return self.role == "ROLE_SUPER_ADMIN"
def is_beneficiary(self) -> bool:
return self.role == "ROLE_BENEFICIARY"
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> AuthUser:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization Bearer mancante",
)
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"JWT non valido: {e}",
)
sub = payload.get("sub", "")
parts = sub.split(":")
email = parts[0] if len(parts) > 0 else ""
hub_id_str = parts[2] if len(parts) > 2 else "0"
return AuthUser(
user_id=int(payload.get("userId", 0)),
email=email,
role=payload.get("auth", ""),
hub_id=int(hub_id_str) if hub_id_str.isdigit() else 0,
)
def require_superadmin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
if not user.is_superadmin():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Richiesto ruolo ROLE_SUPER_ADMIN",
)
return user

36
app/config.py Normal file
View File

@@ -0,0 +1,36 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database (stesso Postgres sandbox del BE Gepafin)
db_host: str = "postgres"
db_port: int = 5432
db_name: str = "gepaDb"
db_user: str = "gepa"
db_password: str = "gepa"
db_schema: str = "gepafin_rendic"
# JWT — deve corrispondere al secret di GEPAFIN-BE
jwt_secret: str = "sandbox-secret-do-not-use-in-prod-minimum-32-chars-padding-ZZZZZZZZZZ"
jwt_algorithm: str = "HS512"
# CORS
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
class Config:
env_file = ".env"
env_prefix = "RENDIC_"
@property
def db_url(self) -> str:
return f"postgresql+psycopg2://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
@property
def cors_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()

24
app/db.py Normal file
View File

@@ -0,0 +1,24 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import get_settings
settings = get_settings()
engine = create_engine(
settings.db_url,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
connect_args={"options": f"-csearch_path={settings.db_schema},public"}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

67
app/main.py Normal file
View File

@@ -0,0 +1,67 @@
"""
rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin.
Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione,
fatture, ULA, soccorso istruttorio.
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic).
Auth: JWT condiviso con GEPAFIN-BE.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .config import get_settings
from .db import engine, Base
from .routers import health, schemas
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("rendicontazione-api")
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Avvio rendicontazione-api")
# Crea schema e tabelle se non esistono (bootstrap sandbox)
try:
with engine.begin() as conn:
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}'))
Base.metadata.create_all(bind=engine)
log.info(f"Schema '{settings.db_schema}' e tabelle inizializzate")
except Exception as e:
log.error(f"Errore bootstrap DB: {e}")
raise
yield
log.info("Shutdown rendicontazione-api")
app = FastAPI(
title="rendicontazione-api",
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router, tags=["health"])
app.include_router(schemas.router)
@app.get("/", tags=["root"])
def root():
return {
"service": "rendicontazione-api",
"version": "0.1.0",
"docs": "/docs",
"health": "/health",
}

34
app/models.py Normal file
View File

@@ -0,0 +1,34 @@
"""
ORM models per rendicontazione-api.
Schema: gepafin_rendic (stesso DB del BE Gepafin sandbox).
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from .db import Base
class CallRemissionSchema(Base):
"""
Schema di rendicontazione per un bando. Uno per call_id.
status: DRAFT (modificabile) -> PUBLISHED (visibile ai beneficiari).
"""
__tablename__ = "call_remission_schema"
__table_args__ = (
UniqueConstraint("call_id", name="uq_call_remission_schema_call_id"),
{"schema": "gepafin_rendic"},
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
call_id = Column(Integer, nullable=False, unique=True)
schema_version = Column(Integer, nullable=False, default=1)
status = Column(String(32), nullable=False, default="DRAFT")
schema_json = Column(JSONB, nullable=False)
created_by = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
published_at = Column(DateTime(timezone=True), nullable=True)
published_by = Column(Integer, nullable=True)

0
app/routers/__init__.py Normal file
View File

24
app/routers/health.py Normal file
View File

@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from ..db import get_db
router = APIRouter()
@router.get("/health")
def health(db: Session = Depends(get_db)):
# Verifica connessione DB e schema
try:
result = db.execute(text("SELECT current_schema(), current_user, now();")).first()
return {
"status": "ok",
"service": "rendicontazione-api",
"db": {
"schema": result[0],
"user": result[1],
"now": str(result[2]),
},
}
except Exception as e:
return {"status": "degraded", "error": str(e)}

162
app/routers/schemas.py Normal file
View File

@@ -0,0 +1,162 @@
"""
Endpoint gestione schema rendicontazione per bando.
"""
import copy
from fastapi import APIRouter, Depends, HTTPException, status
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
router = APIRouter(prefix="/api/rendicontazione-schemas", tags=["rendicontazione-schemas"])
@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 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 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 già 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),
):
"""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"),
)
@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 è più 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 già 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/restart", response_model=ApiResponse)
def get_restart_template(user: AuthUser = Depends(require_superadmin)):
"""Restituisce il template RE-START senza persisterlo. Utile per preview."""
return ApiResponse(data=RESTART_TEMPLATE)

40
app/schemas.py Normal file
View File

@@ -0,0 +1,40 @@
"""
Pydantic schemas per API.
"""
from typing import Optional, Any
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class RemissionSchemaBase(BaseModel):
schema_json: dict = Field(..., description="JSON dello schema di rendicontazione")
class RemissionSchemaCreate(RemissionSchemaBase):
pass
class RemissionSchemaUpdate(BaseModel):
schema_json: Optional[dict] = None
class RemissionSchemaOut(BaseModel):
id: UUID
call_id: int
schema_version: int
status: str
schema_json: dict
created_by: int
created_at: datetime
updated_at: datetime
published_at: Optional[datetime] = None
published_by: Optional[int] = None
model_config = {"from_attributes": True}
class ApiResponse(BaseModel):
status: str = "SUCCESS"
message: Optional[str] = None
data: Optional[Any] = None

113
app/templates.py Normal file
View File

@@ -0,0 +1,113 @@
"""
Template schemi precompilati per bandi noti.
RE-START: il bando del xlsx di Cecilia, base per la prima iterazione.
"""
RESTART_TEMPLATE = {
"version": "1.0",
"template_id": "RESTART_V1",
"template_label": "RE-START (fondo prestiti con remissione del debito)",
"sections": [
{
"type": "static_fields",
"id": "general",
"label": "Dati generali",
"description": "Regime IVA e dati base del beneficiario. ATECO e importo erogato sono pre-compilati dalla domanda approvata.",
"fields": [
{
"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 è rendicontabile. In regime ordinario vale solo l'imponibile.",
}
],
},
{
"type": "category_grid",
"id": "expenses",
"label": "Spese ammissibili per categoria",
"description": "Carica le fatture dentro la categoria appropriata. Totali parziali e complessivo calcolati in tempo reale.",
"categories": [
{
"code": "B1",
"label": "Tecnologie innovative (Industry 4.0, digitale)",
"description": "Hardware, software, soluzioni innovative destinate ad attività produttive",
"cap_amount": None,
},
{
"code": "B2",
"label": "Incremento ULA (occupazione)",
"description": "Costi del personale collegati a incremento di occupazione",
"cap_amount": None,
},
{
"code": "B3",
"label": "Formazione",
"description": "Corsi, docenze, materiali didattici per il personale",
"cap_amount": None,
},
],
"invoice_schema": {
"required_fields": [
"invoice_number",
"invoice_date",
"payment_date",
"supplier_name",
"supplier_vat",
"description",
"taxable",
"vat",
"total",
"pdf",
],
"optional_fields": ["vat_rate", "vat_exempt_reason"],
},
},
{
"type": "ula_block",
"id": "ula",
"label": "Calcolo ULA (incremento occupazione)",
"description": "Per ogni dipendente: codice fiscale, tipologia contratto, percentuale di tempo, periodo. Allegato di supporto obbligatorio (LUL, estratto gestionale, dichiarazione del consulente del lavoro).",
"enabled": True,
"threshold": 1.0,
"period_start_rule": "erogato_date",
"period_end": "2021-12-31",
"supporting_doc_required": True,
"supporting_doc_types": [
{"code": "LUL", "label": "Libro Unico del Lavoro"},
{"code": "GESTIONALE_PAGHE", "label": "Estratto gestionale paghe"},
{"code": "DICHIARAZIONE_CDL", "label": "Dichiarazione Consulente del Lavoro"},
{"code": "ALTRO", "label": "Altro documento di supporto"},
],
},
{
"type": "document_checklist",
"id": "docs",
"label": "Documenti richiesti",
"description": "I documenti già in regola nel repository della Company saranno riutilizzati (semaforo verde). Solo quelli scaduti o mancanti richiedono caricamento.",
"required_types": [
{"code": "DURC", "label": "DURC (Documento Unico di Regolarità Contributiva)"},
{"code": "VISURA_CAMERALE", "label": "Visura camerale aggiornata"},
{"code": "BILANCIO", "label": "Bilancio ultimo esercizio"},
{"code": "ANTIRICICLAGGIO", "label": "Dichiarazione antiriciclaggio"},
],
},
],
"gate_rules": {
"amount_range": {"min": 5000, "max": 25000},
"cap_pct_erogato": 0.5,
"cap_absolute": 12500,
"iva_ordinario_imponibile_only": True,
"period_start_rule": "erogato_date",
"period_end": "2021-12-31",
"require_at_least_one_invoice_per_nonzero_category": True,
"require_ula_above_threshold": True,
"require_all_documents_resolved": True,
},
}

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi==0.110.0
uvicorn[standard]==0.27.1
sqlalchemy==2.0.27
psycopg2-binary==2.9.9
pydantic==2.6.3
pydantic-settings==2.2.1
python-jose[cryptography]==3.3.0
python-multipart==0.0.9