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 where, params = ["active=1"], []68 if q:69 where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")70 like = f"%{q}%"71 params += [like, like, like, like]72 if region:73 where.append("region=?")74 params.append(region)75 if city:76 where.append("city=?")77 params.append(city)78 if category:79 where.append("categories LIKE ?")80 params.append(f'%"{category}"%')81 if free is True:82 where.append("is_free=1")83 if upcoming and not date_from:84 date_from = date.today().isoformat()85 if date_from:86 where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")87 params += [date_from, date_from]88 if date_to:89 where.append("start_date <= ?")90 params.append(date_to)9192 if sort == "date" and date_from:93 # les événements déjà commencés mais toujours en cours (expositions,94 # festivals longs) se classent à leur date effective, pas à leur début95 order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"96 order_params = [date_from]97 elif sort == "date":98 order = "start_date IS NULL, start_date ASC, title ASC"99 order_params = []100 else:101 order, order_params = "updated_at DESC", []102 sql_where = " AND ".join(where)103104 # déduplication inter-sources (CLAUDE.md §12) : un même événement publié105 # par plusieurs sources = une seule carte, la fiche la plus riche gagne106 # (prix connu > GPS > image > description), les autres restent conservées107 # en base et accessibles par uid.108 ranked = (109 "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "110 "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "111 "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "112 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "113 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "114 f"FROM events WHERE {sql_where}")115 con = db.connect()116 total = con.execute(117 f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]118 rows = con.execute(119 f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "120 f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()121 con.close()122 events = []123 for r in rows:124 d = _row_to_dict(dict(r) | {})125 d.pop("rn", None)126 events.append(d)127 return {"total": total, "limit": limit, "offset": offset, "events": events}128129130@app.get("/api/events/{uid}")131def get_event(uid: str) -> dict:132 con = db.connect()133 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()134 con.close()135 if row is None:136 raise HTTPException(404, "événement introuvable")137 return _row_to_dict(row)138139140@app.get("/api/stats")141def stats() -> dict:142 today = date.today().isoformat()143 con = db.connect()144 total = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]145 upcoming = con.execute(146 "SELECT COUNT(*) FROM events WHERE active=1 AND "147 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",148 (today, today)).fetchone()[0]149 free = con.execute(150 "SELECT COUNT(*) FROM events WHERE active=1 AND is_free=1 AND "151 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",152 (today, today)).fetchone()[0]153 by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(154 "SELECT region, COUNT(*) AS n FROM events WHERE active=1 AND "155 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "156 "GROUP BY region ORDER BY n DESC", (today, today))}157 by_category: dict[str, int] = {}158 for r in con.execute(159 "SELECT categories FROM events WHERE active=1 AND "160 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",161 (today, today)):162 for c in json.loads(r["categories"] or "[]"):163 by_category[c] = by_category.get(c, 0) + 1164 cities = con.execute(165 "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"166 ).fetchone()[0]167 con.close()168 # nombre de sources branchées (statut actif du registre) — lu par le hub169 # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.170 try:171 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))172 n_sources = sum(1 for s in reg.get("sources", [])173 if s.get("statut") == "actif")174 except Exception:175 n_sources = 0176 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,177 "sources": n_sources,178 "cities": cities, "by_region": by_region,179 "by_category": dict(sorted(by_category.items(),180 key=lambda kv: -kv[1]))}181182183# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------184SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",185 "tagline": "Toutes les sorties du Québec, un seul endroit."}186187188@app.get("/api/stats/dashboard")189def stats_dashboard(190 period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),191 date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),192 date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),193) -> dict:194 return kstats.dashboard(period, date_from, date_to)195196197@app.get("/api/stats/report")198def stats_report(199 period: str = Query("30j"),200 date_from: str = Query("", alias="from"),201 date_to: str = Query("", alias="to"),202 mode: str = Query("complet", description="complet | synthese"),203) -> Response:204 if mode not in ("complet", "synthese"):205 raise HTTPException(422, "mode invalide (complet | synthese)")206 dash = kstats.dashboard(period, date_from, date_to)207 pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()208 fname = kapdf.filename("sorti-ka", period)209 return Response(pdf, media_type="application/pdf",210 headers={"Content-Disposition":211 f'attachment; filename="{fname}"'})212213214@app.get("/api/regions")215def regions() -> list[str]:216 return REGIONS217218219@app.get("/api/categories")220def categories() -> list[str]:221 return CATEGORIES222223224@app.get("/api/sources")225def sources() -> dict:226 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))227 con = db.connect()228 counts = {r["source"]: r["n"] for r in con.execute(229 "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}230 last = {r["source"]: r["ts"] for r in con.execute(231 "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "232 "GROUP BY source")}233 con.close()234 for s in reg.get("sources", []):235 s["active_events"] = counts.get(s["id"], 0)236 s["last_sync"] = last.get(s["id"])237 return reg238239240# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---241app.include_router(auth.router)242243# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---244app.include_router(seo.router)245246247# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---248if FRONTEND_DIR.exists():249 @app.get("/")250 def index() -> FileResponse:251 return FileResponse(FRONTEND_DIR / "index.html")252253 app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")254