SPB Git forge

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.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
17.2 KB · 405 lines python
Raw Blame History
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 json10import math11from datetime import date12from pathlib import Path1314from fastapi import Body, FastAPI, HTTPException, Query, Request15from fastapi.middleware.cors import CORSMiddleware16from fastapi.middleware.gzip import GZipMiddleware17from fastapi.responses import FileResponse, Response18from fastapi.staticfiles import StaticFiles1920from . import auth, db, hubfav, kaid, kapdf, seo21from . import stats as kstats22from .normalize import CATEGORIES23from .regions import REGIONS2425ROOT = Path(__file__).resolve().parent.parent26SOURCES_PATH = ROOT / "data" / "sources.json"27FRONTEND_DIR = ROOT / "frontend"2829app = FastAPI(title="Sorti-Ka API", version="1.0",30              description="Agrégateur de sorties & événements — province de Québec")31app.add_middleware(CORSMiddleware, allow_origins=["*"],32                   allow_methods=["*"], allow_headers=["*"])33app.add_middleware(GZipMiddleware, minimum_size=1000)3435# personnalisation KA ID v2 (sortika/kaid.py, canonique ka-ui.git/kaid).36# Particularité Sorti·Ka : le tri par défaut est CHRONOLOGIQUE — on ne37# reclasse pas (blend=0), on annote (« Recommandé pour vous »), on exclut38# les masqués et on journalise ; le profil appris profite aux autres univers.39kaid.init("sorti-ka")40app.include_router(kaid.build_router(auth.current_user))414243def _kaid_features(d: dict) -> dict:44    """Caractéristiques d'un événement pour le profil de préférences KA ID."""45    return {k: v for k, v in {46        "city": d.get("city"), "region": d.get("region"),47        "category": d.get("categories") or None,48        "venue": d.get("venue"),49        "is_free": bool(d.get("is_free")) if d.get("is_free") is not None else None,50        "price": d.get("price_min"),51    }.items() if v not in (None, "", [])}525354def _row_to_dict(row) -> dict:55    d = dict(row)56    for k in ("categories", "raw_categories", "artists"):57        d[k] = json.loads(d.get(k) or "[]")58    d["is_free"] = None if d["is_free"] is None else bool(d["is_free"])59    d.pop("content_hash", None)60    return d616263@app.get("/healthz")64@app.get("/api/health")65def health() -> dict:66    con = db.connect()67    n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]68    con.close()69    return {"status": "ok", "active_events": n}707172@app.get("/api/events")73def list_events(74    request: Request,75    q: str = Query("", description="recherche plein texte (titre, lieu, description)"),76    region: str = Query("", description="région administrative"),77    city: str = Query("", description="ville"),78    category: str = Query("", description="catégorie canonique"),79    free: bool | None = Query(None, description="gratuit seulement"),80    date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"),81    date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"),82    upcoming: bool = Query(True, description="masquer les événements passés"),83    sort: str = Query("date", description="date | recent | near"),84    near: str = Query("", description="lat,lng — filtre géographique (rayon radius_km)"),85    radius_km: float = Query(25, ge=1, le=300, description="rayon du filtre near en km"),86    limit: int = Query(30, ge=1, le=200),87    offset: int = Query(0, ge=0),88) -> dict:89    # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un90    # événement quarantainé reste en base et réintègre dès la donnée saine)91    where, params = ["active=1", "quarantine IS NULL"], []92    if q:93        where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")94        like = f"%{q}%"95        params += [like, like, like, like]96    if region:97        where.append("region=?")98        params.append(region)99    if city:100        where.append("city=?")101        params.append(city)102    if category:103        where.append("categories LIKE ?")104        params.append(f'%"{category}"%')105    if free is True:106        where.append("is_free=1")107    if upcoming and not date_from:108        date_from = date.today().isoformat()109    if date_from:110        where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")111        params += [date_from, date_from]112    if date_to:113        where.append("start_date <= ?")114        params.append(date_to)115116    # filtre géographique « près de moi » : boîte englobante autour du point117    # (1° lat ≈ 110,6 km ; 1° lng ≈ 111,3 km × cos(lat)) — assez précis au QC118    nlat = nlng = None119    if near:120        try:121            nlat, nlng = (float(x) for x in near.split(","))122        except ValueError:123            raise HTTPException(422, "near attend « lat,lng »")124        dlat = radius_km / 110.6125        dlng = radius_km / (111.3 * max(0.2, math.cos(math.radians(nlat))))126        where.append("lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?")127        params += [nlat - dlat, nlat + dlat, nlng - dlng, nlng + dlng]128129    if sort == "near" and nlat is not None:130        # distance approchée (carrés de degrés, lng corrigé) — suffit pour trier131        coslat2 = math.cos(math.radians(nlat)) ** 2132        order = f"((lat-?)*(lat-?) + (lng-?)*(lng-?)*{coslat2:.6f}) ASC, start_date ASC"133        order_params = [nlat, nlat, nlng, nlng]134    elif sort == "date" and date_from:135        # les événements déjà commencés mais toujours en cours (expositions,136        # festivals longs) se classent à leur date effective, pas à leur début137        order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"138        order_params = [date_from]139    elif sort == "date":140        order = "start_date IS NULL, start_date ASC, title ASC"141        order_params = []142    else:143        order, order_params = "updated_at DESC", []144    sql_where = " AND ".join(where)145146    # déduplication inter-sources (CLAUDE.md §12) : un même événement publié147    # par plusieurs sources = une seule carte, la fiche la plus riche gagne148    # (prix connu > GPS > image > description), les autres restent conservées149    # en base et accessibles par uid.150    ranked = (151        "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "152        "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "153        "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "154        "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "155        "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "156        f"FROM events WHERE {sql_where}")157    con = db.connect()158    total = con.execute(159        f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]160    rows = con.execute(161        f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "162        f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()163    con.close()164    events = []165    for r in rows:166        d = _row_to_dict(dict(r) | {})167        d.pop("rn", None)168        events.append(d)169    # personnalisation KA ID : journal + annotation (ordre chronologique170    # préservé — blend=0 ; masqués exclus, badge sur les correspondances nettes)171    user = auth.current_user(request)172    if user:173        filters = {k: v for k, v in {"q": q, "region": region, "city": city,174                                     "category": category, "free": free}.items() if v}175        if filters and offset == 0:176            kaid.track(user, "search", query=q or None, filters=filters)177        active = {k for k in ("region", "city", "category") if filters.get(k)}178        if free:179            active.add("is_free")180        events, _ = kaid.rerank(events, user, features_of=_kaid_features,181                                active_dims=active, blend=0.0)182    return {"total": total, "limit": limit, "offset": offset, "events": events}183184185@app.get("/api/events/{uid}")186def get_event(uid: str, request: Request) -> dict:187    con = db.connect()188    row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()189    con.close()190    if row is None:191        raise HTTPException(404, "événement introuvable")192    d = _row_to_dict(row)193    kaid.track(auth.current_user(request), "detail_view", entity_type="event",194               entity_id=uid, features=_kaid_features(d))195    return d196197198@app.get("/api/stats")199def stats() -> dict:200    today = date.today().isoformat()201    pub = "active=1 AND quarantine IS NULL"     # publiables seulement202    con = db.connect()203    total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0]204    quarantined = con.execute(205        "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL"206    ).fetchone()[0]207    upcoming = con.execute(208        f"SELECT COUNT(*) FROM events WHERE {pub} AND "209        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",210        (today, today)).fetchone()[0]211    free = con.execute(212        f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND "213        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",214        (today, today)).fetchone()[0]215    by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(216        f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND "217        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "218        "GROUP BY region ORDER BY n DESC", (today, today))}219    by_category: dict[str, int] = {}220    for r in con.execute(221            f"SELECT categories FROM events WHERE {pub} AND "222            "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",223            (today, today)):224        for c in json.loads(r["categories"] or "[]"):225            by_category[c] = by_category.get(c, 0) + 1226    cities = con.execute(227        f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''"228    ).fetchone()[0]229    con.close()230    # nombre de sources branchées (statut actif du registre) — lu par le hub231    # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.232    try:233        reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))234        n_sources = sum(1 for s in reg.get("sources", [])235                        if s.get("statut") == "actif")236    except Exception:237        n_sources = 0238    return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,239            "quarantined": quarantined,240            "sources": n_sources,241            "cities": cities, "by_region": by_region,242            "by_category": dict(sorted(by_category.items(),243                                       key=lambda kv: -kv[1]))}244245246# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------247SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",248        "tagline": "Toutes les sorties du Québec, un seul endroit."}249250251@app.get("/api/stats/dashboard")252def stats_dashboard(253    period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),254    date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),255    date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),256) -> dict:257    return kstats.dashboard(period, date_from, date_to)258259260@app.get("/api/stats/report")261def stats_report(262    period: str = Query("30j"),263    date_from: str = Query("", alias="from"),264    date_to: str = Query("", alias="to"),265    mode: str = Query("complet", description="complet | synthese | tendances "266                                              "| repartitions | donnees"),267) -> Response:268    if mode not in kapdf.REPORT_MODES:   # mode inconnu → rapport complet (SPEC v2)269        mode = "complet"270    dash = kstats.dashboard(period, date_from, date_to)271    pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()272    fname = kapdf.filename("sorti-ka", period, mode)273    return Response(pdf, media_type="application/pdf",274                    headers={"Content-Disposition":275                             f'attachment; filename="{fname}"'})276277278@app.get("/api/stats/catalog")279def stats_catalog(280    period: str = Query("30j"),281    date_from: str = Query("", alias="from"),282    date_to: str = Query("", alias="to"),283) -> dict:284    """v3 — blocs composables pour le constructeur de rapports personnalisés."""285    dash = kstats.dashboard(period, date_from, date_to)286    return {"updated": dash.get("updated"), "period": dash.get("period"),287            "blocks": kapdf.catalog(dash)}288289290@app.post("/api/stats/report/custom")291def stats_report_custom(spec: dict = Body(...)) -> Response:292    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",293    "blocks": [{"key": "series:ajouts", "render": "bar"}, …]}."""294    period = str(spec.get("period") or "30j")295    dfrom = str(spec.get("from") or "")296    dto = str(spec.get("to") or "")297    dash = kstats.dashboard(period, dfrom, dto)298    known = {b["key"] for b in kapdf.catalog(dash)}299    blocks = [b for b in (spec.get("blocks") or [])300              if isinstance(b, dict) and b.get("key") in known][:40]301    if not blocks:302        raise HTTPException(400, "Aucun bloc valide dans la composition")303    pdf = kapdf.GroupeKAReport(304        site=SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,305        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},306    ).build()307    fname = kapdf.filename("sorti-ka", period, kapdf.CUSTOM_MODE)308    return Response(pdf, media_type="application/pdf",309                    headers={"Content-Disposition":310                             f'attachment; filename="{fname}"'})311312313@app.get("/api/regions")314def regions() -> list[str]:315    return REGIONS316317318@app.get("/api/categories")319def categories() -> list[str]:320    return CATEGORIES321322323@app.get("/api/sources")324def sources() -> dict:325    reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))326    con = db.connect()327    counts = {r["source"]: r["n"] for r in con.execute(328        "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}329    # santé Phase 2 : événements FUTURS par source (une source « verte » sans330    # futur est morte en silence — canal lu par la supervision connecteurs)331    upcoming = db.future_counts(con)332    last = {r["source"]: r["ts"] for r in con.execute(333        "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "334        "GROUP BY source")}335    con.close()336    for s in reg.get("sources", []):337        s["active_events"] = counts.get(s["id"], 0)338        s["upcoming_events"] = upcoming.get(s["id"], 0)339        s["last_sync"] = last.get(s["id"])340    # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json)341    try:342        state = json.loads((ROOT / "data" / "future_counts.json")343                           .read_text(encoding="utf-8"))344        reg["future_alerts"] = state.get("_alerts", [])345    except Exception:346        reg["future_alerts"] = []347    return reg348349350# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----351@app.get("/api/favorites")352def favorites(request: Request):353    """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""354    user = auth.current_user(request)355    if not user:356        raise HTTPException(401, "Connexion KA ID requise")357    items = hubfav.hub_list(user.get("ka_id") or "")358    if items is None:359        raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")360    return {"ids": [i["item_id"] for i in items if i.get("item_id")],361            "items": items}362363364@app.post("/api/favorites/toggle")365def toggle_favorite(request: Request, body: dict = Body(...)):366    """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub367    Groupe KA de façon synchrone : le hub est la seule source de vérité."""368    user = auth.current_user(request)369    if not user:370        raise HTTPException(401, "Connexion KA ID requise")371    ka_id = user.get("ka_id") or ""372    if not hubfav.linked(ka_id):373        raise HTTPException(403, "Compte non relié au hub Groupe KA")374    on = bool(body.get("on"))375    item = hubfav.clean_item(body.get("item") or {})376    if not item.get("item_id"):377        raise HTTPException(422, "item.item_id requis")378    if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):379        raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")380    # signal fort du moteur de préférences (features lues de la BD)381    uid = item["item_id"]382    con = db.connect()383    row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()384    con.close()385    kaid.track(user, "favorite" if on else "unfavorite", entity_type="event",386               entity_id=uid,387               features=_kaid_features(_row_to_dict(row)) if row else None)388    return {"ok": True, "on": on}389390391# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---392app.include_router(auth.router)393394# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---395app.include_router(seo.router)396397398# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---399if FRONTEND_DIR.exists():400    @app.get("/")401    def index() -> FileResponse:402        return FileResponse(FRONTEND_DIR / "index.html")403404    app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")405