- qualify_ai_service : ai_frame_async() avec Claude Haiku · round 1 → questions contextualisées si ANTHROPIC_API_KEY définie · round 2 → explication enrichie par Claude · fallback transparent sur ai_frame() si pas de clé (tests inchangés) - config : ANTHROPIC_API_KEY + ANTHROPIC_MODEL (claude-haiku-4-5-20251001) - requirements : anthropic>=0.97.0 - main : auth rate limit = RATE_LIMIT_DEFAULT partout (prototype mode) → supporte accès démo/test sans lockout en prod comme en dev Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
6.3 KiB
Python
165 lines
6.3 KiB
Python
import logging
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database import get_db, init_db
|
|
from app.middleware.rate_limiter import RateLimiterMiddleware
|
|
from app.middleware.security_headers import SecurityHeadersMiddleware
|
|
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
|
from app.routers import public
|
|
from app.routers import organizations
|
|
from app.routers import qualify
|
|
from app.routers import groups
|
|
|
|
|
|
# ── Structured logging setup ───────────────────────────────────────────────
|
|
|
|
|
|
def _setup_logging() -> None:
|
|
"""Configure structured logging based on environment.
|
|
|
|
- Production/staging: JSON-formatted log lines for log aggregation.
|
|
- Development: human-readable format with colors.
|
|
"""
|
|
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
|
|
|
if settings.ENVIRONMENT in ("production", "staging"):
|
|
# JSON formatter for structured logging
|
|
formatter = logging.Formatter(
|
|
'{"timestamp":"%(asctime)s","level":"%(levelname)s",'
|
|
'"logger":"%(name)s","message":"%(message)s"}',
|
|
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
)
|
|
else:
|
|
# Human-readable format for development
|
|
formatter = logging.Formatter(
|
|
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
|
|
handler = logging.StreamHandler(sys.stdout)
|
|
handler.setFormatter(formatter)
|
|
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(log_level)
|
|
root_logger.handlers.clear()
|
|
root_logger.addHandler(handler)
|
|
|
|
# Reduce noise from third-party libraries
|
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
logging.getLogger("sqlalchemy.engine").setLevel(
|
|
logging.INFO if settings.ENVIRONMENT == "development" else logging.WARNING
|
|
)
|
|
|
|
|
|
_setup_logging()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Application lifespan ───────────────────────────────────────────────────
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logger.info(
|
|
"Demarrage %s (env=%s, log_level=%s)",
|
|
settings.APP_NAME, settings.ENVIRONMENT, settings.LOG_LEVEL,
|
|
)
|
|
await init_db()
|
|
yield
|
|
logger.info("Arret %s", settings.APP_NAME)
|
|
|
|
|
|
# ── FastAPI application ───────────────────────────────────────────────────
|
|
|
|
|
|
app = FastAPI(
|
|
title=settings.APP_NAME,
|
|
description="Plateforme de decisions collectives pour la communaute Duniter/G1",
|
|
version="0.5.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
# ── Middleware stack ──────────────────────────────────────────────────────
|
|
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
|
# last to execute on response (wraps everything inside it).
|
|
#
|
|
# Required order so CORS headers appear on ALL responses including 429:
|
|
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
|
|
#
|
|
# If RateLimiter were outside CORS, its 429 responses would have no CORS
|
|
# headers and the browser would silently discard them as network errors.
|
|
|
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
|
|
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
|
|
# in .env only when going to real production.
|
|
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
|
|
|
|
app.add_middleware(
|
|
RateLimiterMiddleware,
|
|
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
|
rate_limit_auth=_auth_rate_limit,
|
|
rate_limit_vote=settings.RATE_LIMIT_VOTE,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.CORS_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ── Routers ──────────────────────────────────────────────────────────────
|
|
|
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
|
app.include_router(documents.router, prefix="/api/v1/documents", tags=["documents"])
|
|
app.include_router(decisions.router, prefix="/api/v1/decisions", tags=["decisions"])
|
|
app.include_router(votes.router, prefix="/api/v1/votes", tags=["votes"])
|
|
app.include_router(mandates.router, prefix="/api/v1/mandates", tags=["mandates"])
|
|
app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocols"])
|
|
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
|
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
|
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
|
|
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
|
|
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
|
|
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
|
|
|
|
|
|
# ── Health check ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/api/health")
|
|
async def health(db: AsyncSession = Depends(get_db)):
|
|
"""Health check endpoint that verifies database connectivity.
|
|
|
|
Returns status "ok" with database connection info if healthy,
|
|
or status "degraded" if the database is unreachable.
|
|
"""
|
|
try:
|
|
result = await db.execute(text("SELECT 1"))
|
|
result.scalar()
|
|
db_status = "connected"
|
|
except Exception as exc:
|
|
logger.warning("Health check: base de donnees inaccessible - %s", exc)
|
|
db_status = "disconnected"
|
|
|
|
overall_status = "ok" if db_status == "connected" else "degraded"
|
|
|
|
return {
|
|
"status": overall_status,
|
|
"environment": settings.ENVIRONMENT,
|
|
"database": db_status,
|
|
"version": "0.5.0",
|
|
}
|