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.

13.9 KB · 341 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 Body, FastAPI, HTTPException, Query, Request14from fastapi.middleware.cors import CORSMiddleware15from fastapi.middleware.gzip import GZipMiddleware16from fastapi.responses import FileResponse, Response17from fastapi.staticfiles import StaticFiles1819from . import auth, db, hubfav, 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    # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un68    # événement quarantainé reste en base et réintègre dès la donnée saine)69    where, params = ["active=1", "quarantine IS NULL"], []70    if q:71        where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")72        like = f"%{q}%"73        params += [like, like, like, like]74    if region:75        where.append("region=?")76        params.append(region)77    if city:78        where.append("city=?")79        params.append(city)80    if category:81        where.append("categories LIKE ?")82        params.append(f'%"{category}"%')83    if free is True:84        where.append("is_free=1")85    if upcoming and not date_from:86        date_from = date.today().isoformat()87    if date_from:88        where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")89        params += [date_from, date_from]90    if date_to:91        where.append("start_date <= ?")92        params.append(date_to)9394    if sort == "date" and date_from:95        # les événements déjà commencés mais toujours en cours (expositions,96        # festivals longs) se classent à leur date effective, pas à leur début97        order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"98        order_params = [date_from]99    elif sort == "date":100        order = "start_date IS NULL, start_date ASC, title ASC"101        order_params = []102    else:103        order, order_params = "updated_at DESC", []104    sql_where = " AND ".join(where)105106    # déduplication inter-sources (CLAUDE.md §12) : un même événement publié107    # par plusieurs sources = une seule carte, la fiche la plus riche gagne108    # (prix connu > GPS > image > description), les autres restent conservées109    # en base et accessibles par uid.110    ranked = (111        "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "112        "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "113        "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "114        "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "115        "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "116        f"FROM events WHERE {sql_where}")117    con = db.connect()118    total = con.execute(119        f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]120    rows = con.execute(121        f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "122        f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()123    con.close()124    events = []125    for r in rows:126        d = _row_to_dict(dict(r) | {})127        d.pop("rn", None)128        events.append(d)129    return {"total": total, "limit": limit, "offset": offset, "events": events}130131132@app.get("/api/events/{uid}")133def get_event(uid: str) -> dict:134    con = db.connect()135    row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()136    con.close()137    if row is None:138        raise HTTPException(404, "événement introuvable")139    return _row_to_dict(row)140141142@app.get("/api/stats")143def stats() -> dict:144    today = date.today().isoformat()145    pub = "active=1 AND quarantine IS NULL"     # publiables seulement146    con = db.connect()147    total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0]148    quarantined = con.execute(149        "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL"150    ).fetchone()[0]151    upcoming = con.execute(152        f"SELECT COUNT(*) FROM events WHERE {pub} AND "153        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",154        (today, today)).fetchone()[0]155    free = con.execute(156        f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND "157        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",158        (today, today)).fetchone()[0]159    by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(160        f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND "161        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "162        "GROUP BY region ORDER BY n DESC", (today, today))}163    by_category: dict[str, int] = {}164    for r in con.execute(165            f"SELECT categories FROM events WHERE {pub} AND "166            "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",167            (today, today)):168        for c in json.loads(r["categories"] or "[]"):169            by_category[c] = by_category.get(c, 0) + 1170    cities = con.execute(171        f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''"172    ).fetchone()[0]173    con.close()174    # nombre de sources branchées (statut actif du registre) — lu par le hub175    # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.176    try:177        reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))178        n_sources = sum(1 for s in reg.get("sources", [])179                        if s.get("statut") == "actif")180    except Exception:181        n_sources = 0182    return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,183            "quarantined": quarantined,184            "sources": n_sources,185            "cities": cities, "by_region": by_region,186            "by_category": dict(sorted(by_category.items(),187                                       key=lambda kv: -kv[1]))}188189190# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------191SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",192        "tagline": "Toutes les sorties du Québec, un seul endroit."}193194195@app.get("/api/stats/dashboard")196def stats_dashboard(197    period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),198    date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),199    date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),200) -> dict:201    return kstats.dashboard(period, date_from, date_to)202203204@app.get("/api/stats/report")205def stats_report(206    period: str = Query("30j"),207    date_from: str = Query("", alias="from"),208    date_to: str = Query("", alias="to"),209    mode: str = Query("complet", description="complet | synthese | tendances "210                                              "| repartitions | donnees"),211) -> Response:212    if mode not in kapdf.REPORT_MODES:   # mode inconnu → rapport complet (SPEC v2)213        mode = "complet"214    dash = kstats.dashboard(period, date_from, date_to)215    pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()216    fname = kapdf.filename("sorti-ka", period, mode)217    return Response(pdf, media_type="application/pdf",218                    headers={"Content-Disposition":219                             f'attachment; filename="{fname}"'})220221222@app.get("/api/stats/catalog")223def stats_catalog(224    period: str = Query("30j"),225    date_from: str = Query("", alias="from"),226    date_to: str = Query("", alias="to"),227) -> dict:228    """v3 — blocs composables pour le constructeur de rapports personnalisés."""229    dash = kstats.dashboard(period, date_from, date_to)230    return {"updated": dash.get("updated"), "period": dash.get("period"),231            "blocks": kapdf.catalog(dash)}232233234@app.post("/api/stats/report/custom")235def stats_report_custom(spec: dict = Body(...)) -> Response:236    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",237    "blocks": [{"key": "series:ajouts", "render": "bar"}, …]}."""238    period = str(spec.get("period") or "30j")239    dfrom = str(spec.get("from") or "")240    dto = str(spec.get("to") or "")241    dash = kstats.dashboard(period, dfrom, dto)242    known = {b["key"] for b in kapdf.catalog(dash)}243    blocks = [b for b in (spec.get("blocks") or [])244              if isinstance(b, dict) and b.get("key") in known][:40]245    if not blocks:246        raise HTTPException(400, "Aucun bloc valide dans la composition")247    pdf = kapdf.GroupeKAReport(248        site=SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,249        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},250    ).build()251    fname = kapdf.filename("sorti-ka", period, kapdf.CUSTOM_MODE)252    return Response(pdf, media_type="application/pdf",253                    headers={"Content-Disposition":254                             f'attachment; filename="{fname}"'})255256257@app.get("/api/regions")258def regions() -> list[str]:259    return REGIONS260261262@app.get("/api/categories")263def categories() -> list[str]:264    return CATEGORIES265266267@app.get("/api/sources")268def sources() -> dict:269    reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))270    con = db.connect()271    counts = {r["source"]: r["n"] for r in con.execute(272        "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}273    # santé Phase 2 : événements FUTURS par source (une source « verte » sans274    # futur est morte en silence — canal lu par la supervision connecteurs)275    upcoming = db.future_counts(con)276    last = {r["source"]: r["ts"] for r in con.execute(277        "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "278        "GROUP BY source")}279    con.close()280    for s in reg.get("sources", []):281        s["active_events"] = counts.get(s["id"], 0)282        s["upcoming_events"] = upcoming.get(s["id"], 0)283        s["last_sync"] = last.get(s["id"])284    # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json)285    try:286        state = json.loads((ROOT / "data" / "future_counts.json")287                           .read_text(encoding="utf-8"))288        reg["future_alerts"] = state.get("_alerts", [])289    except Exception:290        reg["future_alerts"] = []291    return reg292293294# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----295@app.get("/api/favorites")296def favorites(request: Request):297    """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""298    user = auth.current_user(request)299    if not user:300        raise HTTPException(401, "Connexion KA ID requise")301    items = hubfav.hub_list(user.get("ka_id") or "")302    if items is None:303        raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")304    return {"ids": [i["item_id"] for i in items if i.get("item_id")],305            "items": items}306307308@app.post("/api/favorites/toggle")309def toggle_favorite(request: Request, body: dict = Body(...)):310    """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub311    Groupe KA de façon synchrone : le hub est la seule source de vérité."""312    user = auth.current_user(request)313    if not user:314        raise HTTPException(401, "Connexion KA ID requise")315    ka_id = user.get("ka_id") or ""316    if not hubfav.linked(ka_id):317        raise HTTPException(403, "Compte non relié au hub Groupe KA")318    on = bool(body.get("on"))319    item = hubfav.clean_item(body.get("item") or {})320    if not item.get("item_id"):321        raise HTTPException(422, "item.item_id requis")322    if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):323        raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")324    return {"ok": True, "on": on}325326327# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---328app.include_router(auth.router)329330# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---331app.include_router(seo.router)332333334# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---335if FRONTEND_DIR.exists():336    @app.get("/")337    def index() -> FileResponse:338        return FileResponse(FRONTEND_DIR / "index.html")339340    app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")341
Files · 05f55caa6e093daba371e353621625dfdc9ab950
Menu

Theme