# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # web.py : API FastAPI (JSON) + service du frontend statique (frontend/) # (patron Lou-Ka : louka/web.py) # ----------------------------------------------------------------------------- from __future__ import annotations import json import math from datetime import date from pathlib import Path from fastapi import Body, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles from . import auth, db, hubfav, kaid, kapdf, seo from . import stats as kstats from .normalize import CATEGORIES from .regions import REGIONS ROOT = Path(__file__).resolve().parent.parent SOURCES_PATH = ROOT / "data" / "sources.json" FRONTEND_DIR = ROOT / "frontend" app = FastAPI(title="Sorti-Ka API", version="1.0", description="Agrégateur de sorties & événements — province de Québec") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.add_middleware(GZipMiddleware, minimum_size=1000) # personnalisation KA ID v2 (sortika/kaid.py, canonique ka-ui.git/kaid). # Particularité Sorti·Ka : le tri par défaut est CHRONOLOGIQUE — on ne # reclasse pas (blend=0), on annote (« Recommandé pour vous »), on exclut # les masqués et on journalise ; le profil appris profite aux autres univers. kaid.init("sorti-ka") app.include_router(kaid.build_router(auth.current_user)) def _kaid_features(d: dict) -> dict: """Caractéristiques d'un événement pour le profil de préférences KA ID.""" return {k: v for k, v in { "city": d.get("city"), "region": d.get("region"), "category": d.get("categories") or None, "venue": d.get("venue"), "is_free": bool(d.get("is_free")) if d.get("is_free") is not None else None, "price": d.get("price_min"), }.items() if v not in (None, "", [])} def _row_to_dict(row) -> dict: d = dict(row) for k in ("categories", "raw_categories", "artists"): d[k] = json.loads(d.get(k) or "[]") d["is_free"] = None if d["is_free"] is None else bool(d["is_free"]) d.pop("content_hash", None) return d @app.get("/healthz") @app.get("/api/health") def health() -> dict: con = db.connect() n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0] con.close() return {"status": "ok", "active_events": n} @app.get("/api/events") def list_events( request: Request, q: str = Query("", description="recherche plein texte (titre, lieu, description)"), region: str = Query("", description="région administrative"), city: str = Query("", description="ville"), category: str = Query("", description="catégorie canonique"), free: bool | None = Query(None, description="gratuit seulement"), date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"), date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"), upcoming: bool = Query(True, description="masquer les événements passés"), sort: str = Query("date", description="date | recent | near"), near: str = Query("", description="lat,lng — filtre géographique (rayon radius_km)"), radius_km: float = Query(25, ge=1, le=300, description="rayon du filtre near en km"), limit: int = Query(30, ge=1, le=200), offset: int = Query(0, ge=0), ) -> dict: # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un # événement quarantainé reste en base et réintègre dès la donnée saine) where, params = ["active=1", "quarantine IS NULL"], [] if q: where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)") like = f"%{q}%" params += [like, like, like, like] if region: where.append("region=?") params.append(region) if city: where.append("city=?") params.append(city) if category: where.append("categories LIKE ?") params.append(f'%"{category}"%') if free is True: where.append("is_free=1") if upcoming and not date_from: date_from = date.today().isoformat() if date_from: where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))") params += [date_from, date_from] if date_to: where.append("start_date <= ?") params.append(date_to) # filtre géographique « près de moi » : boîte englobante autour du point # (1° lat ≈ 110,6 km ; 1° lng ≈ 111,3 km × cos(lat)) — assez précis au QC nlat = nlng = None if near: try: nlat, nlng = (float(x) for x in near.split(",")) except ValueError: raise HTTPException(422, "near attend « lat,lng »") dlat = radius_km / 110.6 dlng = radius_km / (111.3 * max(0.2, math.cos(math.radians(nlat)))) where.append("lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?") params += [nlat - dlat, nlat + dlat, nlng - dlng, nlng + dlng] if sort == "near" and nlat is not None: # distance approchée (carrés de degrés, lng corrigé) — suffit pour trier coslat2 = math.cos(math.radians(nlat)) ** 2 order = f"((lat-?)*(lat-?) + (lng-?)*(lng-?)*{coslat2:.6f}) ASC, start_date ASC" order_params = [nlat, nlat, nlng, nlng] elif sort == "date" and date_from: # les événements déjà commencés mais toujours en cours (expositions, # festivals longs) se classent à leur date effective, pas à leur début order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC" order_params = [date_from] elif sort == "date": order = "start_date IS NULL, start_date ASC, title ASC" order_params = [] else: order, order_params = "updated_at DESC", [] sql_where = " AND ".join(where) # déduplication inter-sources (CLAUDE.md §12) : un même événement publié # par plusieurs sources = une seule carte, la fiche la plus riche gagne # (prix connu > GPS > image > description), les autres restent conservées # en base et accessibles par uid. ranked = ( "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY " "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + " "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + " "(CASE WHEN image != '' THEN 2 ELSE 0 END) + " "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn " f"FROM events WHERE {sql_where}") con = db.connect() total = con.execute( f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0] rows = con.execute( f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} " f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall() con.close() events = [] for r in rows: d = _row_to_dict(dict(r) | {}) d.pop("rn", None) events.append(d) # personnalisation KA ID : journal + annotation (ordre chronologique # préservé — blend=0 ; masqués exclus, badge sur les correspondances nettes) user = auth.current_user(request) if user: filters = {k: v for k, v in {"q": q, "region": region, "city": city, "category": category, "free": free}.items() if v} if filters and offset == 0: kaid.track(user, "search", query=q or None, filters=filters) active = {k for k in ("region", "city", "category") if filters.get(k)} if free: active.add("is_free") events, _ = kaid.rerank(events, user, features_of=_kaid_features, active_dims=active, blend=0.0) return {"total": total, "limit": limit, "offset": offset, "events": events} @app.get("/api/events/{uid}") def get_event(uid: str, request: Request) -> dict: con = db.connect() row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone() con.close() if row is None: raise HTTPException(404, "événement introuvable") d = _row_to_dict(row) kaid.track(auth.current_user(request), "detail_view", entity_type="event", entity_id=uid, features=_kaid_features(d)) return d @app.get("/api/stats") def stats() -> dict: today = date.today().isoformat() pub = "active=1 AND quarantine IS NULL" # publiables seulement con = db.connect() total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0] quarantined = con.execute( "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL" ).fetchone()[0] upcoming = con.execute( f"SELECT COUNT(*) FROM events WHERE {pub} AND " "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))", (today, today)).fetchone()[0] free = con.execute( f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND " "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))", (today, today)).fetchone()[0] by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute( f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND " "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) " "GROUP BY region ORDER BY n DESC", (today, today))} by_category: dict[str, int] = {} for r in con.execute( f"SELECT categories FROM events WHERE {pub} AND " "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))", (today, today)): for c in json.loads(r["categories"] or "[]"): by_category[c] = by_category.get(c, 0) + 1 cities = con.execute( f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''" ).fetchone()[0] con.close() # nombre de sources branchées (statut actif du registre) — lu par le hub # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas. try: reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) n_sources = sum(1 for s in reg.get("sources", []) if s.get("statut") == "actif") except Exception: n_sources = 0 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free, "quarantined": quarantined, "sources": n_sources, "cities": cities, "by_region": by_region, "by_category": dict(sorted(by_category.items(), key=lambda kv: -kv[1]))} # --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) ------------- SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com", "tagline": "Toutes les sorties du Québec, un seul endroit."} @app.get("/api/stats/dashboard") def stats_dashboard( period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"), date_from: str = Query("", alias="from", description="ISO — plage personnalisée"), date_to: str = Query("", alias="to", description="ISO — plage personnalisée"), ) -> dict: return kstats.dashboard(period, date_from, date_to) @app.get("/api/stats/report") def stats_report( period: str = Query("30j"), date_from: str = Query("", alias="from"), date_to: str = Query("", alias="to"), mode: str = Query("complet", description="complet | synthese | tendances " "| repartitions | donnees"), ) -> Response: if mode not in kapdf.REPORT_MODES: # mode inconnu → rapport complet (SPEC v2) mode = "complet" dash = kstats.dashboard(period, date_from, date_to) pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build() fname = kapdf.filename("sorti-ka", period, mode) return Response(pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/stats/catalog") def stats_catalog( period: str = Query("30j"), date_from: str = Query("", alias="from"), date_to: str = Query("", alias="to"), ) -> dict: """v3 — blocs composables pour le constructeur de rapports personnalisés.""" dash = kstats.dashboard(period, date_from, date_to) return {"updated": dash.get("updated"), "period": dash.get("period"), "blocks": kapdf.catalog(dash)} @app.post("/api/stats/report/custom") def stats_report_custom(spec: dict = Body(...)) -> Response: """v3 — rapport PDF personnalisé : {"title", "period", "from", "to", "blocks": [{"key": "series:ajouts", "render": "bar"}, …]}.""" period = str(spec.get("period") or "30j") dfrom = str(spec.get("from") or "") dto = str(spec.get("to") or "") dash = kstats.dashboard(period, dfrom, dto) known = {b["key"] for b in kapdf.catalog(dash)} blocks = [b for b in (spec.get("blocks") or []) if isinstance(b, dict) and b.get("key") in known][:40] if not blocks: raise HTTPException(400, "Aucun bloc valide dans la composition") pdf = kapdf.GroupeKAReport( site=SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE, spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks}, ).build() fname = kapdf.filename("sorti-ka", period, kapdf.CUSTOM_MODE) return Response(pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/regions") def regions() -> list[str]: return REGIONS @app.get("/api/categories") def categories() -> list[str]: return CATEGORIES @app.get("/api/sources") def sources() -> dict: reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) con = db.connect() counts = {r["source"]: r["n"] for r in con.execute( "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")} # santé Phase 2 : événements FUTURS par source (une source « verte » sans # futur est morte en silence — canal lu par la supervision connecteurs) upcoming = db.future_counts(con) last = {r["source"]: r["ts"] for r in con.execute( "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL " "GROUP BY source")} con.close() for s in reg.get("sources", []): s["active_events"] = counts.get(s["id"], 0) s["upcoming_events"] = upcoming.get(s["id"], 0) s["last_sync"] = last.get(s["id"]) # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json) try: state = json.loads((ROOT / "data" / "future_counts.json") .read_text(encoding="utf-8")) reg["future_alerts"] = state.get("_alerts", []) except Exception: reg["future_alerts"] = [] return reg # --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ----- @app.get("/api/favorites") def favorites(request: Request): """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") items = hubfav.hub_list(user.get("ka_id") or "") if items is None: raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") return {"ids": [i["item_id"] for i in items if i.get("item_id")], "items": items} @app.post("/api/favorites/toggle") def toggle_favorite(request: Request, body: dict = Body(...)): """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub Groupe KA de façon synchrone : le hub est la seule source de vérité.""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") ka_id = user.get("ka_id") or "" if not hubfav.linked(ka_id): raise HTTPException(403, "Compte non relié au hub Groupe KA") on = bool(body.get("on")) item = hubfav.clean_item(body.get("item") or {}) if not item.get("item_id"): raise HTTPException(422, "item.item_id requis") if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") # signal fort du moteur de préférences (features lues de la BD) uid = item["item_id"] con = db.connect() row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone() con.close() kaid.track(user, "favorite" if on else "unfavorite", entity_type="event", entity_id=uid, features=_kaid_features(_row_to_dict(row)) if row else None) return {"ok": True, "on": on} # --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py --- app.include_router(auth.router) # --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) --- app.include_router(seo.router) # --- frontend statique (à la fin : les routes /api et SEO ont priorité) --- if FRONTEND_DIR.exists(): @app.get("/") def index() -> FileResponse: return FileResponse(FRONTEND_DIR / "index.html") app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")