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.

15.0 KB · 362 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 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, 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)343536def _row_to_dict(row) -> dict:37    d = dict(row)38    for k in ("categories", "raw_categories", "artists"):39        d[k] = json.loads(d.get(k) or "[]")40    d["is_free"] = None if d["is_free"] is None else bool(d["is_free"])41    d.pop("content_hash", None)42    return d434445@app.get("/healthz")46@app.get("/api/health")47def health() -> dict:48    con = db.connect()49    n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]50    con.close()51    return {"status": "ok", "active_events": n}525354@app.get("/api/events")55def list_events(56    q: str = Query("", description="recherche plein texte (titre, lieu, description)"),57    region: str = Query("", description="région administrative"),58    city: str = Query("", description="ville"),59    category: str = Query("", description="catégorie canonique"),60    free: bool | None = Query(None, description="gratuit seulement"),61    date_from: str = Query("", alias="from", description="ISO — événements se terminant à partir de cette date"),62    date_to: str = Query("", alias="to", description="ISO — événements commençant au plus tard à cette date"),63    upcoming: bool = Query(True, description="masquer les événements passés"),64    sort: str = Query("date", description="date | recent | near"),65    near: str = Query("", description="lat,lng — filtre géographique (rayon radius_km)"),66    radius_km: float = Query(25, ge=1, le=300, description="rayon du filtre near en km"),67    limit: int = Query(30, ge=1, le=200),68    offset: int = Query(0, ge=0),69) -> dict:70    # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un71    # événement quarantainé reste en base et réintègre dès la donnée saine)72    where, params = ["active=1", "quarantine IS NULL"], []73    if q:74        where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")75        like = f"%{q}%"76        params += [like, like, like, like]77    if region:78        where.append("region=?")79        params.append(region)80    if city:81        where.append("city=?")82        params.append(city)83    if category:84        where.append("categories LIKE ?")85        params.append(f'%"{category}"%')86    if free is True:87        where.append("is_free=1")88    if upcoming and not date_from:89        date_from = date.today().isoformat()90    if date_from:91        where.append("(end_date >= ? OR (end_date IS NULL AND start_date >= ?))")92        params += [date_from, date_from]93    if date_to:94        where.append("start_date <= ?")95        params.append(date_to)9697    # filtre géographique « près de moi » : boîte englobante autour du point98    # (1° lat ≈ 110,6 km ; 1° lng ≈ 111,3 km × cos(lat)) — assez précis au QC99    nlat = nlng = None100    if near:101        try:102            nlat, nlng = (float(x) for x in near.split(","))103        except ValueError:104            raise HTTPException(422, "near attend « lat,lng »")105        dlat = radius_km / 110.6106        dlng = radius_km / (111.3 * max(0.2, math.cos(math.radians(nlat))))107        where.append("lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?")108        params += [nlat - dlat, nlat + dlat, nlng - dlng, nlng + dlng]109110    if sort == "near" and nlat is not None:111        # distance approchée (carrés de degrés, lng corrigé) — suffit pour trier112        coslat2 = math.cos(math.radians(nlat)) ** 2113        order = f"((lat-?)*(lat-?) + (lng-?)*(lng-?)*{coslat2:.6f}) ASC, start_date ASC"114        order_params = [nlat, nlat, nlng, nlng]115    elif sort == "date" and date_from:116        # les événements déjà commencés mais toujours en cours (expositions,117        # festivals longs) se classent à leur date effective, pas à leur début118        order = "start_date IS NULL, MAX(start_date, ?) ASC, title ASC"119        order_params = [date_from]120    elif sort == "date":121        order = "start_date IS NULL, start_date ASC, title ASC"122        order_params = []123    else:124        order, order_params = "updated_at DESC", []125    sql_where = " AND ".join(where)126127    # déduplication inter-sources (CLAUDE.md §12) : un même événement publié128    # par plusieurs sources = une seule carte, la fiche la plus riche gagne129    # (prix connu > GPS > image > description), les autres restent conservées130    # en base et accessibles par uid.131    ranked = (132        "SELECT *, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY "133        "(CASE WHEN price_min IS NOT NULL THEN 8 ELSE 0 END) + "134        "(CASE WHEN lat IS NOT NULL THEN 4 ELSE 0 END) + "135        "(CASE WHEN image != '' THEN 2 ELSE 0 END) + "136        "(CASE WHEN description != '' THEN 1 ELSE 0 END) DESC, uid) AS rn "137        f"FROM events WHERE {sql_where}")138    con = db.connect()139    total = con.execute(140        f"SELECT COUNT(*) FROM ({ranked}) WHERE rn = 1", params).fetchone()[0]141    rows = con.execute(142        f"SELECT * FROM ({ranked}) WHERE rn = 1 ORDER BY {order} "143        f"LIMIT ? OFFSET ?", params + order_params + [limit, offset]).fetchall()144    con.close()145    events = []146    for r in rows:147        d = _row_to_dict(dict(r) | {})148        d.pop("rn", None)149        events.append(d)150    return {"total": total, "limit": limit, "offset": offset, "events": events}151152153@app.get("/api/events/{uid}")154def get_event(uid: str) -> dict:155    con = db.connect()156    row = con.execute("SELECT * FROM events WHERE uid=?", (uid,)).fetchone()157    con.close()158    if row is None:159        raise HTTPException(404, "événement introuvable")160    return _row_to_dict(row)161162163@app.get("/api/stats")164def stats() -> dict:165    today = date.today().isoformat()166    pub = "active=1 AND quarantine IS NULL"     # publiables seulement167    con = db.connect()168    total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0]169    quarantined = con.execute(170        "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL"171    ).fetchone()[0]172    upcoming = con.execute(173        f"SELECT COUNT(*) FROM events WHERE {pub} AND "174        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",175        (today, today)).fetchone()[0]176    free = con.execute(177        f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND "178        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",179        (today, today)).fetchone()[0]180    by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(181        f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND "182        "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "183        "GROUP BY region ORDER BY n DESC", (today, today))}184    by_category: dict[str, int] = {}185    for r in con.execute(186            f"SELECT categories FROM events WHERE {pub} AND "187            "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",188            (today, today)):189        for c in json.loads(r["categories"] or "[]"):190            by_category[c] = by_category.get(c, 0) + 1191    cities = con.execute(192        f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''"193    ).fetchone()[0]194    con.close()195    # nombre de sources branchées (statut actif du registre) — lu par le hub196    # groupe-ka ; les connecteurs « prêt — clé requise » ne comptent pas.197    try:198        reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))199        n_sources = sum(1 for s in reg.get("sources", [])200                        if s.get("statut") == "actif")201    except Exception:202        n_sources = 0203    return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,204            "quarantined": quarantined,205            "sources": n_sources,206            "cities": cities, "by_region": by_region,207            "by_category": dict(sorted(by_category.items(),208                                       key=lambda kv: -kv[1]))}209210211# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------212SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",213        "tagline": "Toutes les sorties du Québec, un seul endroit."}214215216@app.get("/api/stats/dashboard")217def stats_dashboard(218    period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),219    date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),220    date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),221) -> dict:222    return kstats.dashboard(period, date_from, date_to)223224225@app.get("/api/stats/report")226def stats_report(227    period: str = Query("30j"),228    date_from: str = Query("", alias="from"),229    date_to: str = Query("", alias="to"),230    mode: str = Query("complet", description="complet | synthese | tendances "231                                              "| repartitions | donnees"),232) -> Response:233    if mode not in kapdf.REPORT_MODES:   # mode inconnu → rapport complet (SPEC v2)234        mode = "complet"235    dash = kstats.dashboard(period, date_from, date_to)236    pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()237    fname = kapdf.filename("sorti-ka", period, mode)238    return Response(pdf, media_type="application/pdf",239                    headers={"Content-Disposition":240                             f'attachment; filename="{fname}"'})241242243@app.get("/api/stats/catalog")244def stats_catalog(245    period: str = Query("30j"),246    date_from: str = Query("", alias="from"),247    date_to: str = Query("", alias="to"),248) -> dict:249    """v3 — blocs composables pour le constructeur de rapports personnalisés."""250    dash = kstats.dashboard(period, date_from, date_to)251    return {"updated": dash.get("updated"), "period": dash.get("period"),252            "blocks": kapdf.catalog(dash)}253254255@app.post("/api/stats/report/custom")256def stats_report_custom(spec: dict = Body(...)) -> Response:257    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",258    "blocks": [{"key": "series:ajouts", "render": "bar"}, …]}."""259    period = str(spec.get("period") or "30j")260    dfrom = str(spec.get("from") or "")261    dto = str(spec.get("to") or "")262    dash = kstats.dashboard(period, dfrom, dto)263    known = {b["key"] for b in kapdf.catalog(dash)}264    blocks = [b for b in (spec.get("blocks") or [])265              if isinstance(b, dict) and b.get("key") in known][:40]266    if not blocks:267        raise HTTPException(400, "Aucun bloc valide dans la composition")268    pdf = kapdf.GroupeKAReport(269        site=SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,270        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},271    ).build()272    fname = kapdf.filename("sorti-ka", period, kapdf.CUSTOM_MODE)273    return Response(pdf, media_type="application/pdf",274                    headers={"Content-Disposition":275                             f'attachment; filename="{fname}"'})276277278@app.get("/api/regions")279def regions() -> list[str]:280    return REGIONS281282283@app.get("/api/categories")284def categories() -> list[str]:285    return CATEGORIES286287288@app.get("/api/sources")289def sources() -> dict:290    reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))291    con = db.connect()292    counts = {r["source"]: r["n"] for r in con.execute(293        "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}294    # santé Phase 2 : événements FUTURS par source (une source « verte » sans295    # futur est morte en silence — canal lu par la supervision connecteurs)296    upcoming = db.future_counts(con)297    last = {r["source"]: r["ts"] for r in con.execute(298        "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "299        "GROUP BY source")}300    con.close()301    for s in reg.get("sources", []):302        s["active_events"] = counts.get(s["id"], 0)303        s["upcoming_events"] = upcoming.get(s["id"], 0)304        s["last_sync"] = last.get(s["id"])305    # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json)306    try:307        state = json.loads((ROOT / "data" / "future_counts.json")308                           .read_text(encoding="utf-8"))309        reg["future_alerts"] = state.get("_alerts", [])310    except Exception:311        reg["future_alerts"] = []312    return reg313314315# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----316@app.get("/api/favorites")317def favorites(request: Request):318    """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""319    user = auth.current_user(request)320    if not user:321        raise HTTPException(401, "Connexion KA ID requise")322    items = hubfav.hub_list(user.get("ka_id") or "")323    if items is None:324        raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")325    return {"ids": [i["item_id"] for i in items if i.get("item_id")],326            "items": items}327328329@app.post("/api/favorites/toggle")330def toggle_favorite(request: Request, body: dict = Body(...)):331    """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub332    Groupe KA de façon synchrone : le hub est la seule source de vérité."""333    user = auth.current_user(request)334    if not user:335        raise HTTPException(401, "Connexion KA ID requise")336    ka_id = user.get("ka_id") or ""337    if not hubfav.linked(ka_id):338        raise HTTPException(403, "Compte non relié au hub Groupe KA")339    on = bool(body.get("on"))340    item = hubfav.clean_item(body.get("item") or {})341    if not item.get("item_id"):342        raise HTTPException(422, "item.item_id requis")343    if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):344        raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")345    return {"ok": True, "on": on}346347348# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---349app.include_router(auth.router)350351# --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---352app.include_router(seo.router)353354355# --- frontend statique (à la fin : les routes /api et SEO ont priorité) ---356if FRONTEND_DIR.exists():357    @app.get("/")358    def index() -> FileResponse:359        return FileResponse(FRONTEND_DIR / "index.html")360361    app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")362
Files · 318cadab2403aed402c8e2e2a601748af8e41fb9
Menu

Theme