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.9 KB · 254 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", "artists"):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 > GPS > image > description), les autres restent conservées107    # en base et accessibles par uid.108    ranked = (109        "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "110        "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "111        "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "112        "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "113        "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "114        f"FROM events WHERE {sql_where}")115    con = db.connect()116    total = con.execute(117        f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]118    rows = con.execute(119        f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "120        f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()121    con.close()122    events = []123    for r in rows:124        d = _row_to_dict(dict(r) | {})125        d.pop("rn", None)126        events.append(d)127    return {"total": total, "limit": limit, "offset": offset, "events": events}128129130@app.get("/api/events/{uid}")131def get_event(uid: str) -> dict:132    con = db.connect()133    row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()134    con.close()135    if row is None:136        raise HTTPException(404, "événement introuvable")137    return _row_to_dict(row)138139140@app.get("/api/stats")141def stats() -> dict:142    today = date.today().isoformat()143    con = db.connect()144    total = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]145    upcoming = con.execute(146        "SELECT COUNT(*) FROM events WHERE active=1 AND "147        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",148        (today, today)).fetchone()[0]149    free = con.execute(150        "SELECT COUNT(*) FROM events WHERE active=1 AND is_free=1 AND "151        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",152        (today, today)).fetchone()[0]153    by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(154        "SELECT region, COUNT(*) AS n FROM events WHERE active=1 AND "155        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "156        "GROUP BY region ORDER BY n DESC", (today, today))}157    by_category: dict[str, int] = {}158    for r in con.execute(159            "SELECT categories FROM events WHERE active=1 AND "160            "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",161            (today, today)):162        for c in json.loads(r["categories"] or "[]"):163            by_category[c] = by_category.get(c, 0) + 1164    cities = con.execute(165        "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"166    ).fetchone()[0]167    con.close()168    # nombre de sources branchées (statut actif du registre) — lu par le hub169    # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.170    try:171        reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))172        n_sources = sum(1 for s in reg.get("sources", [])173                        if s.get("statut") == "actif")174    except Exception:175        n_sources = 0176    return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,177            "sources": n_sources,178            "cities": cities, "by_region": by_region,179            "by_category": dict(sorted(by_category.items(),180                                       key=lambda kv: -kv[1]))}181182183# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------184SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",185        "tagline": "Toutes les sorties du Québec, un seul endroit."}186187188@app.get("/api/stats/dashboard")189def stats_dashboard(190    period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),191    date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),192    date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),193) -> dict:194    return kstats.dashboard(period, date_from, date_to)195196197@app.get("/api/stats/report")198def stats_report(199    period: str = Query("30j"),200    date_from: str = Query("", alias="from"),201    date_to: str = Query("", alias="to"),202    mode: str = Query("complet", description="complet | synthese"),203) -> Response:204    if mode not in ("complet", "synthese"):205        raise HTTPException(422, "mode invalide (complet | synthese)")206    dash = kstats.dashboard(period, date_from, date_to)207    pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()208    fname = kapdf.filename("sorti-ka", period)209    return Response(pdf, media_type="application/pdf",210                    headers={"Content-Disposition":211                             f'attachment; filename="{fname}"'})212213214@app.get("/api/regions")215def regions() -> list[str]:216    return REGIONS217218219@app.get("/api/categories")220def categories() -> list[str]:221    return CATEGORIES222223224@app.get("/api/sources")225def sources() -> dict:226    reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))227    con = db.connect()228    counts = {r["source"]: r["n"] for r in con.execute(229        "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}230    last = {r["source"]: r["ts"] for r in con.execute(231        "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "232        "GROUP BY source")}233    con.close()234    for s in reg.get("sources", []):235        s["active_events"] = counts.get(s["id"], 0)236        s["last_sync"] = last.get(s["id"])237    return reg238239240# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---241app.include_router(auth.router)242243# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---244app.include_router(seo.router)245246247# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---248if FRONTEND_DIR.exists():249    @app.get("/")250    def index() -> FileResponse:251        return FileResponse(FRONTEND_DIR / "index.html")252253    app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")254
Files · e312e162a1bc94b78d23b14f87541035565a74a6
Menu

Theme