From 63fd2f66e63086186d7e21eab9c09421493b25c6 Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Sat, 18 Apr 2026 07:50:06 +0200 Subject: [PATCH] initial skeleton: FastAPI + SQLAlchemy + schema rendicontazione + template RE-START --- .gitignore | 9 +++ Dockerfile | 13 ++++ app/__init__.py | 0 app/auth.py | 72 ++++++++++++++++++ app/config.py | 36 +++++++++ app/db.py | 24 ++++++ app/main.py | 67 +++++++++++++++++ app/models.py | 34 +++++++++ app/routers/__init__.py | 0 app/routers/health.py | 24 ++++++ app/routers/schemas.py | 162 ++++++++++++++++++++++++++++++++++++++++ app/schemas.py | 40 ++++++++++ app/templates.py | 113 ++++++++++++++++++++++++++++ requirements.txt | 8 ++ 14 files changed, 602 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/health.py create mode 100644 app/routers/schemas.py create mode 100644 app/schemas.py create mode 100644 app/templates.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed26bb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.env +.venv/ +venv/ +*.log +.pytest_cache/ +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2c2c782 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..1e637e3 --- /dev/null +++ b/app/auth.py @@ -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 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ff0c74d --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..b3af8f4 --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b3839de --- /dev/null +++ b/app/main.py @@ -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", + } diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..f19504b --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..daa5131 --- /dev/null +++ b/app/routers/health.py @@ -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)} diff --git a/app/routers/schemas.py b/app/routers/schemas.py new file mode 100644 index 0000000..1a3f726 --- /dev/null +++ b/app/routers/schemas.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..b41eca7 --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/templates.py b/app/templates.py new file mode 100644 index 0000000..3afed95 --- /dev/null +++ b/app/templates.py @@ -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, + }, +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f630ff4 --- /dev/null +++ b/requirements.txt @@ -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