SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

9.4 KB · 243 lines python
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
Files · 1ca59aef2013d5ac6c8c0aaff0e8e32e697d8059
Menu

Theme