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 json10import math11from datetime import date12from pathlib import Path1314from fastapi import Body, FastAPI, HTTPException, Query, Request15from fastapi.middleware.cors import CORSMiddleware16from fastapi.middleware.gzip import GZipMiddleware17from fastapi.responses import FileResponse, Response18from fastapi.staticfiles import StaticFiles1920from . import auth, db, hubfav, kapdf, seo21from . import stats as kstats22from .normalize import CATEGORIES23from .regions import REGIONS2425ROOT = Path(__file__).resolve().parent.parent26SOURCES_PATH = ROOT / "data" / "sources.json"27FRONTEND_DIR = ROOT / "frontend"2829app = FastAPI(title="Sorti-Ka API", version="1.0",30 description="Agrégateur de sorties & événements — province de Québec")31app.add_middleware(CORSMiddleware, allow_origins=["*"],32 allow_methods=["*"], allow_headers=["*"])33app.add_middleware(GZipMiddleware, minimum_size=1000)343536def _row_to_dict(row) -> dict:37 d = dict(row)38 for k in ("categories", "raw_categories", "artists"):39 d[k] = json.loads(d.get(k) or "[]")40 d["is_free"] = None if d["is_free"] is None else bool(d["is_free"])41 d.pop("content_hash", None)42 return d434445@app.get("/healthz")46@app.get("/api/health")47def health() -> dict:48 con = db.connect()49 n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]50 con.close()51 return {"status": "ok", "active_events": n}525354@app.get("/api/events")55def list_events(56 q: str = Query("", description="recherche plein texte (titre, lieu, description)"),57 region: str = Query("", description="région administrative"),58 city: str = Query("", description="ville"),59 category: str = Query("", description="catégorie canonique"),60 free: bool | None = Query(None, description="gratuit seulement"),61 date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"),62 date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"),63 upcoming: bool = Query(True, description="masquer les événements passés"),64 sort: str = Query("date", description="date | recent | near"),65 near: str = Query("", description="lat,lng — filtre géographique (rayon radius_km)"),66 radius_km: float = Query(25, ge=1, le=300, description="rayon du filtre near en km"),67 limit: int = Query(30, ge=1, le=200),68 offset: int = Query(0, ge=0),69) -> dict:70 # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un71 # événement quarantainé reste en base et réintègre dès la donnée saine)72 where, params = ["active=1", "quarantine IS NULL"], []73 if q:74 where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")75 like = f"%{q}%"76 params += [like, like, like, like]77 if region:78 where.append("region=?")79 params.append(region)80 if city:81 where.append("city=?")82 params.append(city)83 if category:84 where.append("categories LIKE ?")85 params.append(f'%"{category}"%')86 if free is True:87 where.append("is_free=1")88 if upcoming and not date_from:89 date_from = date.today().isoformat()90 if date_from:91 where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")92 params += [date_from, date_from]93 if date_to:94 where.append("start_date <= ?")95 params.append(date_to)9697 # filtre géographique « près de moi » : boîte englobante autour du point98 # (1° lat ≈ 110,6 km ; 1° lng ≈ 111,3 km × cos(lat)) — assez précis au QC99 nlat = nlng = None100 if near:101 try:102 nlat, nlng = (float(x) for x in near.split(","))103 except ValueError:104 raise HTTPException(422, "near attend « lat,lng »")105 dlat = radius_km / 110.6106 dlng = radius_km / (111.3 * max(0.2, math.cos(math.radians(nlat))))107 where.append("lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?")108 params += [nlat - dlat, nlat + dlat, nlng - dlng, nlng + dlng]109110 if sort == "near" and nlat is not None:111 # distance approchée (carrés de degrés, lng corrigé) — suffit pour trier112 coslat2 = math.cos(math.radians(nlat)) ** 2113 order = f"((lat-?)*(lat-?) + (lng-?)*(lng-?)*{coslat2:.6f}) ASC, start_date ASC"114 order_params = [nlat, nlat, nlng, nlng]115 elif sort == "date" and date_from:116 # les événements déjà commencés mais toujours en cours (expositions,117 # festivals longs) se classent à leur date effective, pas à leur début118 order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"119 order_params = [date_from]120 elif sort == "date":121 order = "start_date IS NULL, start_date ASC, title ASC"122 order_params = []123 else:124 order, order_params = "updated_at DESC", []125 sql_where = " AND ".join(where)126127 # déduplication inter-sources (CLAUDE.md §12) : un même événement publié128 # par plusieurs sources = une seule carte, la fiche la plus riche gagne129 # (prix connu > GPS > image > description), les autres restent conservées130 # en base et accessibles par uid.131 ranked = (132 "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "133 "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "134 "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "135 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "136 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "137 f"FROM events WHERE {sql_where}")138 con = db.connect()139 total = con.execute(140 f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]141 rows = con.execute(142 f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "143 f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()144 con.close()145 events = []146 for r in rows:147 d = _row_to_dict(dict(r) | {})148 d.pop("rn", None)149 events.append(d)150 return {"total": total, "limit": limit, "offset": offset, "events": events}151152153@app.get("/api/events/{uid}")154def get_event(uid: str) -> dict:155 con = db.connect()156 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()157 con.close()158 if row is None:159 raise HTTPException(404, "événement introuvable")160 return _row_to_dict(row)161162163@app.get("/api/stats")164def stats() -> dict:165 today = date.today().isoformat()166 pub = "active=1 AND quarantine IS NULL" # publiables seulement167 con = db.connect()168 total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0]169 quarantined = con.execute(170 "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL"171 ).fetchone()[0]172 upcoming = con.execute(173 f"SELECT COUNT(*) FROM events WHERE {pub} AND "174 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",175 (today, today)).fetchone()[0]176 free = con.execute(177 f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND "178 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",179 (today, today)).fetchone()[0]180 by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(181 f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND "182 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "183 "GROUP BY region ORDER BY n DESC", (today, today))}184 by_category: dict[str, int] = {}185 for r in con.execute(186 f"SELECT categories FROM events WHERE {pub} AND "187 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",188 (today, today)):189 for c in json.loads(r["categories"] or "[]"):190 by_category[c] = by_category.get(c, 0) + 1191 cities = con.execute(192 f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''"193 ).fetchone()[0]194 con.close()195 # nombre de sources branchées (statut actif du registre) — lu par le hub196 # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.197 try:198 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))199 n_sources = sum(1 for s in reg.get("sources", [])200 if s.get("statut") == "actif")201 except Exception:202 n_sources = 0203 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,204 "quarantined": quarantined,205 "sources": n_sources,206 "cities": cities, "by_region": by_region,207 "by_category": dict(sorted(by_category.items(),208 key=lambda kv: -kv[1]))}209210211# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------212SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",213 "tagline": "Toutes les sorties du Québec, un seul endroit."}214215216@app.get("/api/stats/dashboard")217def stats_dashboard(218 period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),219 date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),220 date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),221) -> dict:222 return kstats.dashboard(period, date_from, date_to)223224225@app.get("/api/stats/report")226def stats_report(227 period: str = Query("30j"),228 date_from: str = Query("", alias="from"),229 date_to: str = Query("", alias="to"),230 mode: str = Query("complet", description="complet | synthese | tendances "231 "| repartitions | donnees"),232) -> Response:233 if mode not in kapdf.REPORT_MODES: # mode inconnu → rapport complet (SPEC v2)234 mode = "complet"235 dash = kstats.dashboard(period, date_from, date_to)236 pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()237 fname = kapdf.filename("sorti-ka", period, mode)238 return Response(pdf, media_type="application/pdf",239 headers={"Content-Disposition":240 f'attachment; filename="{fname}"'})241242243@app.get("/api/stats/catalog")244def stats_catalog(245 period: str = Query("30j"),246 date_from: str = Query("", alias="from"),247 date_to: str = Query("", alias="to"),248) -> dict:249 """v3 — blocs composables pour le constructeur de rapports personnalisés."""250 dash = kstats.dashboard(period, date_from, date_to)251 return {"updated": dash.get("updated"), "period": dash.get("period"),252 "blocks": kapdf.catalog(dash)}253254255@app.post("/api/stats/report/custom")256def stats_report_custom(spec: dict = Body(...)) -> Response:257 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",258 "blocks": [{"key": "series:ajouts", "render": "bar"}, …]}."""259 period = str(spec.get("period") or "30j")260 dfrom = str(spec.get("from") or "")261 dto = str(spec.get("to") or "")262 dash = kstats.dashboard(period, dfrom, dto)263 known = {b["key"] for b in kapdf.catalog(dash)}264 blocks = [b for b in (spec.get("blocks") or [])265 if isinstance(b, dict) and b.get("key") in known][:40]266 if not blocks:267 raise HTTPException(400, "Aucun bloc valide dans la composition")268 pdf = kapdf.GroupeKAReport(269 site=SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,270 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},271 ).build()272 fname = kapdf.filename("sorti-ka", period, kapdf.CUSTOM_MODE)273 return Response(pdf, media_type="application/pdf",274 headers={"Content-Disposition":275 f'attachment; filename="{fname}"'})276277278@app.get("/api/regions")279def regions() -> list[str]:280 return REGIONS281282283@app.get("/api/categories")284def categories() -> list[str]:285 return CATEGORIES286287288@app.get("/api/sources")289def sources() -> dict:290 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))291 con = db.connect()292 counts = {r["source"]: r["n"] for r in con.execute(293 "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}294 # santé Phase 2 : événements FUTURS par source (une source « verte » sans295 # futur est morte en silence — canal lu par la supervision connecteurs)296 upcoming = db.future_counts(con)297 last = {r["source"]: r["ts"] for r in con.execute(298 "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "299 "GROUP BY source")}300 con.close()301 for s in reg.get("sources", []):302 s["active_events"] = counts.get(s["id"], 0)303 s["upcoming_events"] = upcoming.get(s["id"], 0)304 s["last_sync"] = last.get(s["id"])305 # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json)306 try:307 state = json.loads((ROOT / "data" / "future_counts.json")308 .read_text(encoding="utf-8"))309 reg["future_alerts"] = state.get("_alerts", [])310 except Exception:311 reg["future_alerts"] = []312 return reg313314315# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----316@app.get("/api/favorites")317def favorites(request: Request):318 """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""319 user = auth.current_user(request)320 if not user:321 raise HTTPException(401, "Connexion KA ID requise")322 items = hubfav.hub_list(user.get("ka_id") or "")323 if items is None:324 raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")325 return {"ids": [i["item_id"] for i in items if i.get("item_id")],326 "items": items}327328329@app.post("/api/favorites/toggle")330def toggle_favorite(request: Request, body: dict = Body(...)):331 """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub332 Groupe KA de façon synchrone : le hub est la seule source de vérité."""333 user = auth.current_user(request)334 if not user:335 raise HTTPException(401, "Connexion KA ID requise")336 ka_id = user.get("ka_id") or ""337 if not hubfav.linked(ka_id):338 raise HTTPException(403, "Compte non relié au hub Groupe KA")339 on = bool(body.get("on"))340 item = hubfav.clean_item(body.get("item") or {})341 if not item.get("item_id"):342 raise HTTPException(422, "item.item_id requis")343 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):344 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")345 return {"ok": True, "on": on}346347348# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---349app.include_router(auth.router)350351# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---352app.include_router(seo.router)353354355# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---356if FRONTEND_DIR.exists():357 @app.get("/")358 def index() -> FileResponse:359 return FileResponse(FRONTEND_DIR / "index.html")360361 app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")362