initial skeleton: FastAPI + SQLAlchemy + schema rendicontazione + template RE-START
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.log
|
||||||
|
.pytest_cache/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
0
app/__init__.py
Normal file
72
app/auth.py
Normal file
72
app/auth.py
Normal 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
36
app/config.py
Normal 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
24
app/db.py
Normal 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
67
app/main.py
Normal 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
34
app/models.py
Normal 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
0
app/routers/__init__.py
Normal file
24
app/routers/health.py
Normal file
24
app/routers/health.py
Normal 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
162
app/routers/schemas.py
Normal 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
40
app/schemas.py
Normal 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
113
app/templates.py
Normal 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
8
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user