Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
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, kaid, 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)3435# personnalisation KA ID v2 (sortika/kaid.py, canonique ka-ui.git/kaid).36# Particularité Sorti·Ka : le tri par défaut est CHRONOLOGIQUE — on ne37# reclasse pas (blend=0), on annote (« Recommandé pour vous »), on exclut38# les masqués et on journalise ; le profil appris profite aux autres univers.39kaid.init("sorti-ka")40app.include_router(kaid.build_router(auth.current_user))414243def _kaid_features(d: dict) -> dict:44 """Caractéristiques d'un événement pour le profil de préférences KA ID."""45 return {k: v for k, v in {46 "city": d.get("city"), "region": d.get("region"),47 "category": d.get("categories") or None,48 "venue": d.get("venue"),49 "is_free": bool(d.get("is_free")) if d.get("is_free") is not None else None,50 "price": d.get("price_min"),51 }.items() if v not in (None, "", [])}525354def _row_to_dict(row) -> dict:55 d = dict(row)56 for k in ("categories", "raw_categories", "artists"):57 d[k] = json.loads(d.get(k) or "[]")58 d["is_free"] = None if d["is_free"] is None else bool(d["is_free"])59 d.pop("content_hash", None)60 return d616263@app.get("/healthz")64@app.get("/api/health")65def health() -> dict:66 con = db.connect()67 n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]68 con.close()69 return {"status": "ok", "active_events": n}707172@app.get("/api/events")73def list_events(74 request: Request,75 q: str = Query("", description="recherche plein texte (titre, lieu, description)"),76 region: str = Query("", description="région administrative"),77 city: str = Query("", description="ville"),78 category: str = Query("", description="catégorie canonique"),79 free: bool | None = Query(None, description="gratuit seulement"),80 date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"),81 date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"),82 upcoming: bool = Query(True, description="masquer les événements passés"),83 sort: str = Query("date", description="date | recent | near"),84 near: str = Query("", description="lat,lng — filtre géographique (rayon radius_km)"),85 radius_km: float = Query(25, ge=1, le=300, description="rayon du filtre near en km"),86 limit: int = Query(30, ge=1, le=200),87 offset: int = Query(0, ge=0),88) -> dict:89 # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un90 # événement quarantainé reste en base et réintègre dès la donnée saine)91 where, params = ["active=1", "quarantine IS NULL"], []92 if q:93 where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")94 like = f"%{q}%"95 params += [like, like, like, like]96 if region:97 where.append("region=?")98 params.append(region)99 if city:100 where.append("city=?")101 params.append(city)102 if category:103 where.append("categories LIKE ?")104 params.append(f'%"{category}"%')105 if free is True:106 where.append("is_free=1")107 if upcoming and not date_from:108 date_from = date.today().isoformat()109 if date_from:110 where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")111 params += [date_from, date_from]112 if date_to:113 where.append("start_date <= ?")114 params.append(date_to)115116 # filtre géographique « près de moi » : boîte englobante autour du point117 # (1° lat ≈ 110,6 km ; 1° lng ≈ 111,3 km × cos(lat)) — assez précis au QC118 nlat = nlng = None119 if near:120 try:121 nlat, nlng = (float(x) for x in near.split(","))122 except ValueError:123 raise HTTPException(422, "near attend « lat,lng »")124 dlat = radius_km / 110.6125 dlng = radius_km / (111.3 * max(0.2, math.cos(math.radians(nlat))))126 where.append("lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?")127 params += [nlat - dlat, nlat + dlat, nlng - dlng, nlng + dlng]128129 if sort == "near" and nlat is not None:130 # distance approchée (carrés de degrés, lng corrigé) — suffit pour trier131 coslat2 = math.cos(math.radians(nlat)) ** 2132 order = f"((lat-?)*(lat-?) + (lng-?)*(lng-?)*{coslat2:.6f}) ASC, start_date ASC"133 order_params = [nlat, nlat, nlng, nlng]134 elif sort == "date" and date_from:135 # les événements déjà commencés mais toujours en cours (expositions,136 # festivals longs) se classent à leur date effective, pas à leur début137 order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"138 order_params = [date_from]139 elif sort == "date":140 order = "start_date IS NULL, start_date ASC, title ASC"141 order_params = []142 else:143 order, order_params = "updated_at DESC", []144 sql_where = " AND ".join(where)145146 # déduplication inter-sources (CLAUDE.md §12) : un même événement publié147 # par plusieurs sources = une seule carte, la fiche la plus riche gagne148 # (prix connu > GPS > image > description), les autres restent conservées149 # en base et accessibles par uid.150 ranked = (151 "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "152 "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "153 "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "154 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "155 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "156 f"FROM events WHERE {sql_where}")157 con = db.connect()158 total = con.execute(159 f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]160 rows = con.execute(161 f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "162 f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()163 con.close()164 events = []165 for r in rows:166 d = _row_to_dict(dict(r) | {})167 d.pop("rn", None)168 events.append(d)169 # personnalisation KA ID : journal + annotation (ordre chronologique170 # préservé — blend=0 ; masqués exclus, badge sur les correspondances nettes)171 user = auth.current_user(request)172 if user:173 filters = {k: v for k, v in {"q": q, "region": region, "city": city,174 "category": category, "free": free}.items() if v}175 if filters and offset == 0:176 kaid.track(user, "search", query=q or None, filters=filters)177 active = {k for k in ("region", "city", "category") if filters.get(k)}178 if free:179 active.add("is_free")180 events, _ = kaid.rerank(events, user, features_of=_kaid_features,181 active_dims=active, blend=0.0)182 return {"total": total, "limit": limit, "offset": offset, "events": events}183184185@app.get("/api/events/{uid}")186def get_event(uid: str, request: Request) -> dict:187 con = db.connect()188 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()189 con.close()190 if row is None:191 raise HTTPException(404, "événement introuvable")192 d = _row_to_dict(row)193 kaid.track(auth.current_user(request), "detail_view", entity_type="event",194 entity_id=uid, features=_kaid_features(d))195 return d196197198@app.get("/api/stats")199def stats() -> dict:200 today = date.today().isoformat()201 pub = "active=1 AND quarantine IS NULL" # publiables seulement202 con = db.connect()203 total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0]204 quarantined = con.execute(205 "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL"206 ).fetchone()[0]207 upcoming = con.execute(208 f"SELECT COUNT(*) FROM events WHERE {pub} AND "209 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",210 (today, today)).fetchone()[0]211 free = con.execute(212 f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND "213 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",214 (today, today)).fetchone()[0]215 by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(216 f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND "217 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "218 "GROUP BY region ORDER BY n DESC", (today, today))}219 by_category: dict[str, int] = {}220 for r in con.execute(221 f"SELECT categories FROM events WHERE {pub} AND "222 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",223 (today, today)):224 for c in json.loads(r["categories"] or "[]"):225 by_category[c] = by_category.get(c, 0) + 1226 cities = con.execute(227 f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''"228 ).fetchone()[0]229 con.close()230 # nombre de sources branchées (statut actif du registre) — lu par le hub231 # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.232 try:233 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))234 n_sources = sum(1 for s in reg.get("sources", [])235 if s.get("statut") == "actif")236 except Exception:237 n_sources = 0238 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,239 "quarantined": quarantined,240 "sources": n_sources,241 "cities": cities, "by_region": by_region,242 "by_category": dict(sorted(by_category.items(),243 key=lambda kv: -kv[1]))}244245246# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------247SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",248 "tagline": "Toutes les sorties du Québec, un seul endroit."}249250251@app.get("/api/stats/dashboard")252def stats_dashboard(253 period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),254 date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),255 date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),256) -> dict:257 return kstats.dashboard(period, date_from, date_to)258259260@app.get("/api/stats/report")261def stats_report(262 period: str = Query("30j"),263 date_from: str = Query("", alias="from"),264 date_to: str = Query("", alias="to"),265 mode: str = Query("complet", description="complet | synthese | tendances "266 "| repartitions | donnees"),267) -> Response:268 if mode not in kapdf.REPORT_MODES: # mode inconnu → rapport complet (SPEC v2)269 mode = "complet"270 dash = kstats.dashboard(period, date_from, date_to)271 pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()272 fname = kapdf.filename("sorti-ka", period, mode)273 return Response(pdf, media_type="application/pdf",274 headers={"Content-Disposition":275 f'attachment; filename="{fname}"'})276277278@app.get("/api/stats/catalog")279def stats_catalog(280 period: str = Query("30j"),281 date_from: str = Query("", alias="from"),282 date_to: str = Query("", alias="to"),283) -> dict:284 """v3 — blocs composables pour le constructeur de rapports personnalisés."""285 dash = kstats.dashboard(period, date_from, date_to)286 return {"updated": dash.get("updated"), "period": dash.get("period"),287 "blocks": kapdf.catalog(dash)}288289290@app.post("/api/stats/report/custom")291def stats_report_custom(spec: dict = Body(...)) -> Response:292 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",293 "blocks": [{"key": "series:ajouts", "render": "bar"}, …]}."""294 period = str(spec.get("period") or "30j")295 dfrom = str(spec.get("from") or "")296 dto = str(spec.get("to") or "")297 dash = kstats.dashboard(period, dfrom, dto)298 known = {b["key"] for b in kapdf.catalog(dash)}299 blocks = [b for b in (spec.get("blocks") or [])300 if isinstance(b, dict) and b.get("key") in known][:40]301 if not blocks:302 raise HTTPException(400, "Aucun bloc valide dans la composition")303 pdf = kapdf.GroupeKAReport(304 site=SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,305 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},306 ).build()307 fname = kapdf.filename("sorti-ka", period, kapdf.CUSTOM_MODE)308 return Response(pdf, media_type="application/pdf",309 headers={"Content-Disposition":310 f'attachment; filename="{fname}"'})311312313@app.get("/api/regions")314def regions() -> list[str]:315 return REGIONS316317318@app.get("/api/categories")319def categories() -> list[str]:320 return CATEGORIES321322323@app.get("/api/sources")324def sources() -> dict:325 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))326 con = db.connect()327 counts = {r["source"]: r["n"] for r in con.execute(328 "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}329 # santé Phase 2 : événements FUTURS par source (une source « verte » sans330 # futur est morte en silence — canal lu par la supervision connecteurs)331 upcoming = db.future_counts(con)332 last = {r["source"]: r["ts"] for r in con.execute(333 "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "334 "GROUP BY source")}335 con.close()336 for s in reg.get("sources", []):337 s["active_events"] = counts.get(s["id"], 0)338 s["upcoming_events"] = upcoming.get(s["id"], 0)339 s["last_sync"] = last.get(s["id"])340 # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json)341 try:342 state = json.loads((ROOT / "data" / "future_counts.json")343 .read_text(encoding="utf-8"))344 reg["future_alerts"] = state.get("_alerts", [])345 except Exception:346 reg["future_alerts"] = []347 return reg348349350# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----351@app.get("/api/favorites")352def favorites(request: Request):353 """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""354 user = auth.current_user(request)355 if not user:356 raise HTTPException(401, "Connexion KA ID requise")357 items = hubfav.hub_list(user.get("ka_id") or "")358 if items is None:359 raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")360 return {"ids": [i["item_id"] for i in items if i.get("item_id")],361 "items": items}362363364@app.post("/api/favorites/toggle")365def toggle_favorite(request: Request, body: dict = Body(...)):366 """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub367 Groupe KA de façon synchrone : le hub est la seule source de vérité."""368 user = auth.current_user(request)369 if not user:370 raise HTTPException(401, "Connexion KA ID requise")371 ka_id = user.get("ka_id") or ""372 if not hubfav.linked(ka_id):373 raise HTTPException(403, "Compte non relié au hub Groupe KA")374 on = bool(body.get("on"))375 item = hubfav.clean_item(body.get("item") or {})376 if not item.get("item_id"):377 raise HTTPException(422, "item.item_id requis")378 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):379 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")380 # signal fort du moteur de préférences (features lues de la BD)381 uid = item["item_id"]382 con = db.connect()383 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()384 con.close()385 kaid.track(user, "favorite" if on else "unfavorite", entity_type="event",386 entity_id=uid,387 features=_kaid_features(_row_to_dict(row)) if row else None)388 return {"ok": True, "on": on}389390391# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---392app.include_router(auth.router)393394# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---395app.include_router(seo.router)396397398# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---399if FRONTEND_DIR.exists():400 @app.get("/")401 def index() -> FileResponse:402 return FileResponse(FRONTEND_DIR / "index.html")403404 app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")405