Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# web.py : API FastAPI (JSON) + service du frontend statique (frontend/)5# (patron Lou-Ka : louka/web.py)6# -----------------------------------------------------------------------------7from __future__ import annotations89import json10from datetime import date11from pathlib import Path1213from fastapi import FastAPI, HTTPException, Query14from fastapi.middleware.cors import CORSMiddleware15from fastapi.middleware.gzip import GZipMiddleware16from fastapi.responses import FileResponse, Response17from fastapi.staticfiles import StaticFiles1819from . import auth, db, kapdf, seo20from . import stats as kstats21from .normalize import CATEGORIES22from .regions import REGIONS2324ROOT = Path(__file__).resolve().parent.parent25SOURCES_PATH = ROOT / "data" / "sources.json"26FRONTEND_DIR = ROOT / "frontend"2728app = FastAPI(title="Sorti-Ka API", version="1.0",29 description="Agrégateur de sorties & événements — province de Québec")30app.add_middleware(CORSMiddleware, allow_origins=["*"],31 allow_methods=["*"], allow_headers=["*"])32app.add_middleware(GZipMiddleware, minimum_size=1000)333435def _row_to_dict(row) -> dict:36 d = dict(row)37 for k in ("categories", "raw_categories", "artists"):38 d[k] = json.loads(d.get(k) or "[]")39 d["is_free"] = None if d["is_free"] is None else bool(d["is_free"])40 d.pop("content_hash", None)41 return d424344@app.get("/healthz")45@app.get("/api/health")46def health() -> dict:47 con = db.connect()48 n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]49 con.close()50 return {"status": "ok", "active_events": n}515253@app.get("/api/events")54def list_events(55 q: str = Query("", description="recherche plein texte (titre, lieu, description)"),56 region: str = Query("", description="région administrative"),57 city: str = Query("", description="ville"),58 category: str = Query("", description="catégorie canonique"),59 free: bool | None = Query(None, description="gratuit seulement"),60 date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"),61 date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"),62 upcoming: bool = Query(True, description="masquer les événements passés"),63 sort: str = Query("date", description="date | recent"),64 limit: int = Query(30, ge=1, le=200),65 offset: int = Query(0, ge=0),66) -> dict:67 # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un68 # événement quarantainé reste en base et réintègre dès la donnée saine)69 where, params = ["active=1", "quarantine IS NULL"], []70 if q:71 where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")72 like = f"%{q}%"73 params += [like, like, like, like]74 if region:75 where.append("region=?")76 params.append(region)77 if city:78 where.append("city=?")79 params.append(city)80 if category:81 where.append("categories LIKE ?")82 params.append(f'%"{category}"%')83 if free is True:84 where.append("is_free=1")85 if upcoming and not date_from:86 date_from = date.today().isoformat()87 if date_from:88 where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")89 params += [date_from, date_from]90 if date_to:91 where.append("start_date <= ?")92 params.append(date_to)9394 if sort == "date" and date_from:95 # les événements déjà commencés mais toujours en cours (expositions,96 # festivals longs) se classent à leur date effective, pas à leur début97 order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"98 order_params = [date_from]99 elif sort == "date":100 order = "start_date IS NULL, start_date ASC, title ASC"101 order_params = []102 else:103 order, order_params = "updated_at DESC", []104 sql_where = " AND ".join(where)105106 # déduplication inter-sources (CLAUDE.md §12) : un même événement publié107 # par plusieurs sources = une seule carte, la fiche la plus riche gagne108 # (prix connu > GPS > image > description), les autres restent conservées109 # en base et accessibles par uid.110 ranked = (111 "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "112 "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "113 "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "114 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "115 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "116 f"FROM events WHERE {sql_where}")117 con = db.connect()118 total = con.execute(119 f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]120 rows = con.execute(121 f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "122 f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()123 con.close()124 events = []125 for r in rows:126 d = _row_to_dict(dict(r) | {})127 d.pop("rn", None)128 events.append(d)129 return {"total": total, "limit": limit, "offset": offset, "events": events}130131132@app.get("/api/events/{uid}")133def get_event(uid: str) -> dict:134 con = db.connect()135 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()136 con.close()137 if row is None:138 raise HTTPException(404, "événement introuvable")139 return _row_to_dict(row)140141142@app.get("/api/stats")143def stats() -> dict:144 today = date.today().isoformat()145 pub = "active=1 AND quarantine IS NULL" # publiables seulement146 con = db.connect()147 total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0]148 quarantined = con.execute(149 "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL"150 ).fetchone()[0]151 upcoming = con.execute(152 f"SELECT COUNT(*) FROM events WHERE {pub} AND "153 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",154 (today, today)).fetchone()[0]155 free = con.execute(156 f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND "157 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",158 (today, today)).fetchone()[0]159 by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(160 f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND "161 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "162 "GROUP BY region ORDER BY n DESC", (today, today))}163 by_category: dict[str, int] = {}164 for r in con.execute(165 f"SELECT categories FROM events WHERE {pub} AND "166 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",167 (today, today)):168 for c in json.loads(r["categories"] or "[]"):169 by_category[c] = by_category.get(c, 0) + 1170 cities = con.execute(171 f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''"172 ).fetchone()[0]173 con.close()174 # nombre de sources branchées (statut actif du registre) — lu par le hub175 # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.176 try:177 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))178 n_sources = sum(1 for s in reg.get("sources", [])179 if s.get("statut") == "actif")180 except Exception:181 n_sources = 0182 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,183 "quarantined": quarantined,184 "sources": n_sources,185 "cities": cities, "by_region": by_region,186 "by_category": dict(sorted(by_category.items(),187 key=lambda kv: -kv[1]))}188189190# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------191SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",192 "tagline": "Toutes les sorties du Québec, un seul endroit."}193194195@app.get("/api/stats/dashboard")196def stats_dashboard(197 period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),198 date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),199 date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),200) -> dict:201 return kstats.dashboard(period, date_from, date_to)202203204@app.get("/api/stats/report")205def stats_report(206 period: str = Query("30j"),207 date_from: str = Query("", alias="from"),208 date_to: str = Query("", alias="to"),209 mode: str = Query("complet", description="complet | synthese | tendances "210 "| repartitions | donnees"),211) -> Response:212 if mode not in kapdf.REPORT_MODES: # mode inconnu → rapport complet (SPEC v2)213 mode = "complet"214 dash = kstats.dashboard(period, date_from, date_to)215 pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()216 fname = kapdf.filename("sorti-ka", period, mode)217 return Response(pdf, media_type="application/pdf",218 headers={"Content-Disposition":219 f'attachment; filename="{fname}"'})220221222@app.get("/api/regions")223def regions() -> list[str]:224 return REGIONS225226227@app.get("/api/categories")228def categories() -> list[str]:229 return CATEGORIES230231232@app.get("/api/sources")233def sources() -> dict:234 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))235 con = db.connect()236 counts = {r["source"]: r["n"] for r in con.execute(237 "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}238 # santé Phase 2 : événements FUTURS par source (une source « verte » sans239 # futur est morte en silence — canal lu par la supervision connecteurs)240 upcoming = db.future_counts(con)241 last = {r["source"]: r["ts"] for r in con.execute(242 "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "243 "GROUP BY source")}244 con.close()245 for s in reg.get("sources", []):246 s["active_events"] = counts.get(s["id"], 0)247 s["upcoming_events"] = upcoming.get(s["id"], 0)248 s["last_sync"] = last.get(s["id"])249 # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json)250 try:251 state = json.loads((ROOT / "data" / "future_counts.json")252 .read_text(encoding="utf-8"))253 reg["future_alerts"] = state.get("_alerts", [])254 except Exception:255 reg["future_alerts"] = []256 return reg257258259# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---260app.include_router(auth.router)261262# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---263app.include_router(seo.router)264265266# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---267if FRONTEND_DIR.exists():268 @app.get("/")269 def index() -> FileResponse:270 return FileResponse(FRONTEND_DIR / "index.html")271272 app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")273