# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Application FastAPI — API + frontend statique + rafraîchissement planifié.""" from __future__ import annotations import asyncio import logging import time from collections import defaultdict, deque from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .config import FRONTEND_DIR, settings from .db import init_db from .api.public import router as public_router, router_v3 from .api.admin import router as admin_router logging.basicConfig( level=logging.INFO, format='{"t":"%(asctime)s","lvl":"%(levelname)s","mod":"%(name)s","msg":"%(message)s"}') log = logging.getLogger("app") # --- Limitation de débit simple (par IP, fenêtre glissante) ------------------- RATE_LIMITS = {"POST": (30, 60.0), "GET": (240, 60.0)} # (requêtes, fenêtre s) _hits: dict[str, deque] = defaultdict(deque) async def rate_limiter(request: Request, call_next): if request.url.path.startswith("/api"): limit, window = RATE_LIMITS.get(request.method, (240, 60.0)) key = f"{request.client.host if request.client else '?'}|{request.method}" now = time.monotonic() dq = _hits[key] while dq and now - dq[0] > window: dq.popleft() if len(dq) >= limit: return JSONResponse({"detail": "Trop de requêtes — réessayez sous peu."}, status_code=429) dq.append(now) return await call_next(request) async def _scheduler(): """Rafraîchissement automatique du pipeline (collecte + modèle + simulation). Cadence resserrée à 3 h en période de campagne (≤ 45 jours du scrutin) : les sondages tombent chaque jour, ils doivent entrer sans délai.""" from datetime import date as _date from .pipeline import run_pipeline await asyncio.sleep(60) while True: try: report = await asyncio.to_thread(run_pipeline, True) log.info(f"pipeline: {report}") except Exception as e: # le service continue même si un cycle échoue log.error(f"pipeline en échec: {e}") days_left = (settings.election_date - _date.today()).days hours = 3.0 if 0 <= days_left <= 45 else settings.refresh_interval_hours await asyncio.sleep(hours * 3600) @asynccontextmanager async def lifespan(app: FastAPI): init_db() task = asyncio.create_task(_scheduler()) yield task.cancel() app = FastAPI(title=settings.app_name, version=settings.model_version, lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json") app.middleware("http")(rate_limiter) app.include_router(public_router) app.include_router(router_v3) app.include_router(admin_router) @app.get("/health") def health(): return {"status": "ok", "app": settings.app_name, "version": settings.model_version} PAGES = ["index", "aujourdhui", "carte", "circonscriptions", "sondages", "signaux", "simulateur", "intelligence", "methodologie", "backtest", "admin"] # Cache-busting : la version du modèle est injectée dans les URLs d'assets # (style.css?v=…, app.js?v=…) et les pages HTML sont servies en no-cache — # chaque release est visible immédiatement, sans vider le cache du navigateur. _page_cache: dict[str, tuple[float, str]] = {} def _render_page(name: str) -> str: f = FRONTEND_DIR / f"{name}.html" mtime = f.stat().st_mtime cached = _page_cache.get(name) if cached and cached[0] == mtime: return cached[1] html = f.read_text() v = settings.model_version html = html.replace('href="/assets/style.css"', f'href="/assets/style.css?v={v}"') html = html.replace('src="/assets/app.js"', f'src="/assets/app.js?v={v}"') _page_cache[name] = (mtime, html) return html for page in PAGES: path = "/" if page == "index" else f"/{page}" def make_handler(p=page): def handler(): from fastapi.responses import HTMLResponse return HTMLResponse(_render_page(p), headers={"Cache-Control": "no-cache"}) return handler app.api_route(path, methods=["GET", "HEAD"], include_in_schema=False)(make_handler()) app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")