# ----------------------------------------------------------------------------- # 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 from datetime import date from pathlib import Path from fastapi import FastAPI, HTTPException, Query 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, 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) def _row_to_dict(row) -> dict: d = dict(row) for k in ("categories", "raw_categories"): 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( 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"), limit: int = Query(30, ge=1, le=200), offset: int = Query(0, ge=0), ) -> dict: where, params = ["active=1"], [] 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) if 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 > image > description), les autres restent accessibles par uid. ranked = ( "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY " "(CASE WHEN price_min 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) return {"total": total, "limit": limit, "offset": offset, "events": events} @app.get("/api/events/{uid}") def get_event(uid: str) -> 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") return _row_to_dict(row) @app.get("/api/stats") def stats() -> dict: today = date.today().isoformat() con = db.connect() total = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0] upcoming = con.execute( "SELECT COUNT(*) FROM events WHERE active=1 AND " "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))", (today, today)).fetchone()[0] free = con.execute( "SELECT COUNT(*) FROM events WHERE active=1 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( "SELECT region, COUNT(*) AS n FROM events WHERE active=1 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( "SELECT categories FROM events WHERE active=1 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( "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''" ).fetchone()[0] con.close() return {"total_active": total, "upcoming": upcoming, "free_upcoming": free, "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"), ) -> Response: if mode not in ("complet", "synthese"): raise HTTPException(422, "mode invalide (complet | synthese)") dash = kstats.dashboard(period, date_from, date_to) pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build() fname = kapdf.filename("sorti-ka", period) 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")} 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["last_sync"] = last.get(s["id"]) return reg # --- 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")