Repository Public
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"):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 > image > description), les autres restent accessibles par uid.107 ranked = (108 "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "109 "(CASE WHEN price_min IS NOT NULL THEN 4 ELSE 0 END) + "110 "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "111 "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "112 f"FROM events WHERE {sql_where}")113 con = db.connect()114 total = con.execute(115 f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]116 rows = con.execute(117 f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "118 f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()119 con.close()120 events = []121 for r in rows:122 d = _row_to_dict(dict(r) | {})123 d.pop("rn", None)124 events.append(d)125 return {"total": total, "limit": limit, "offset": offset, "events": events}126127128@app.get("/api/events/{uid}")129def get_event(uid: str) -> dict:130 con = db.connect()131 row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()132 con.close()133 if row is None:134 raise HTTPException(404, "événement introuvable")135 return _row_to_dict(row)136137138@app.get("/api/stats")139def stats() -> dict:140 today = date.today().isoformat()141 con = db.connect()142 total = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]143 upcoming = con.execute(144 "SELECT COUNT(*) FROM events WHERE active=1 AND "145 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",146 (today, today)).fetchone()[0]147 free = con.execute(148 "SELECT COUNT(*) FROM events WHERE active=1 AND is_free=1 AND "149 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",150 (today, today)).fetchone()[0]151 by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(152 "SELECT region, COUNT(*) AS n FROM events WHERE active=1 AND "153 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "154 "GROUP BY region ORDER BY n DESC", (today, today))}155 by_category: dict[str, int] = {}156 for r in con.execute(157 "SELECT categories FROM events WHERE active=1 AND "158 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",159 (today, today)):160 for c in json.loads(r["categories"] or "[]"):161 by_category[c] = by_category.get(c, 0) + 1162 cities = con.execute(163 "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"164 ).fetchone()[0]165 con.close()166 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,167 "cities": cities, "by_region": by_region,168 "by_category": dict(sorted(by_category.items(),169 key=lambda kv: -kv[1]))}170171172# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------173SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",174 "tagline": "Toutes les sorties du Québec, un seul endroit."}175176177@app.get("/api/stats/dashboard")178def stats_dashboard(179 period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),180 date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),181 date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),182) -> dict:183 return kstats.dashboard(period, date_from, date_to)184185186@app.get("/api/stats/report")187def stats_report(188 period: str = Query("30j"),189 date_from: str = Query("", alias="from"),190 date_to: str = Query("", alias="to"),191 mode: str = Query("complet", description="complet | synthese"),192) -> Response:193 if mode not in ("complet", "synthese"):194 raise HTTPException(422, "mode invalide (complet | synthese)")195 dash = kstats.dashboard(period, date_from, date_to)196 pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()197 fname = kapdf.filename("sorti-ka", period)198 return Response(pdf, media_type="application/pdf",199 headers={"Content-Disposition":200 f'attachment; filename="{fname}"'})201202203@app.get("/api/regions")204def regions() -> list[str]:205 return REGIONS206207208@app.get("/api/categories")209def categories() -> list[str]:210 return CATEGORIES211212213@app.get("/api/sources")214def sources() -> dict:215 reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))216 con = db.connect()217 counts = {r["source"]: r["n"] for r in con.execute(218 "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}219 last = {r["source"]: r["ts"] for r in con.execute(220 "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "221 "GROUP BY source")}222 con.close()223 for s in reg.get("sources", []):224 s["active_events"] = counts.get(s["id"], 0)225 s["last_sync"] = last.get(s["id"])226 return reg227228229# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---230app.include_router(auth.router)231232# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---233app.include_router(seo.router)234235236# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---237if FRONTEND_DIR.exists():238 @app.get("/")239 def index() -> FileResponse:240 return FileResponse(FRONTEND_DIR / "index.html")241242 app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")243