SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
28.3 KB · 671 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/web.py4# Desc:   API FastAPI (JSON) + service du frontend React (frontend/dist).5#         Recherche des restaurants (région, ville, cuisine, diète, prix, texte),6#         fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout >7#         delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py.8# ==============================================================================9from __future__ import annotations1011import json12import statistics13import threading14from pathlib import Path1516from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request17from fastapi.middleware.cors import CORSMiddleware18from fastapi.middleware.gzip import GZipMiddleware19from fastapi.responses import FileResponse, RedirectResponse, Response20from fastapi.staticfiles import StaticFiles2122from . import db, hubfav, ingest, kaid23from .normalize import PRICE_CONTEXTS24from .regions import REGIONS2526ROOT = Path(__file__).resolve().parent.parent27SOURCES_PATH = ROOT / "data" / "sources.json"28FRONTEND_DIST = ROOT / "frontend" / "dist"2930# préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2)31_CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)}3233app = FastAPI(title="Resto-Ka API", version="1.0",34              description="Agrégateur des restaurants du Québec — menus & prix")35app.add_middleware(CORSMiddleware, allow_origins=["*"],36                   allow_methods=["*"], allow_headers=["*"])37app.add_middleware(GZipMiddleware, minimum_size=1000)3839_sync_lock = threading.Lock()4041# comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py42from . import auth  # noqa: E402  (import tardif : évite le cycle web<->auth)43app.include_router(auth.router)4445# personnalisation KA ID v2 (restoka/kaid.py, canonique ka-ui.git/kaid)46kaid.init("resto-ka")47app.include_router(kaid.build_router(auth.current_user))484950def _kaid_features(d: dict) -> dict:51    """Caractéristiques d'un restaurant pour le profil de préférences KA ID."""52    return {k: v for k, v in {53        "city": d.get("city"), "region": d.get("region"),54        "cuisine": d.get("cuisines") or None,55        "establishment_type": d.get("establishment_type"),56        "price_range": d.get("price_range") or None,57        "chain": d.get("chain") or None,58    }.items() if v not in (None, "", [])}5960# référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py61# inclus AVANT le fallback SPA pour que ces routes aient priorité62from . import seo  # noqa: E40263app.include_router(seo.router)646566def _row_to_dict(row) -> dict:67    d = dict(row)68    for col in ("cuisines", "services", "dietary_options", "languages", "images"):69        d[col] = json.loads(d.get(col) or "[]")70    d["hours"] = json.loads(d.get("hours") or "{}")71    try:  # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details72        d["details"] = json.loads(d.get("details") or "{}")73    except (ValueError, TypeError):74        d["details"] = {}75    if d.get("dup_sources"):76        try:77            d["dup_sources"] = json.loads(d["dup_sources"])78        except (ValueError, TypeError):79            d["dup_sources"] = []80    return d818283def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:84    """Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes."""85    if not uids:86        return {}87    qmarks = ",".join("?" * len(uids))88    best: dict[str, dict] = {}89    for r in con.execute(90            f"SELECT uid, price_context, price_source, captured_at, item_count,"91            f" sections FROM menus WHERE uid IN ({qmarks})", uids):92        cur = best.get(r["uid"])93        if cur and _CTX_ORDER.get(cur["price_context"], 9) <= \94                _CTX_ORDER.get(r["price_context"], 9):95            continue96        sections = json.loads(r["sections"] or "[]")97        prices = [it.get("price")98                  for sec in sections99                  for it in sec.get("items") or []100                  if isinstance(it.get("price"), (int, float)) and it["price"] > 0]101        image = next((it["image"] for sec in sections102                      for it in sec.get("items") or [] if it.get("image")),103                     next((sec["image"] for sec in sections104                           if sec.get("image")), None))105        best[r["uid"]] = {106            "image": image,107            "price_context": r["price_context"],108            "price_source": r["price_source"],109            "captured_at": r["captured_at"],110            "item_count": r["item_count"],111            "price_min": min(prices) if prices else None,112            "price_median": round(statistics.median(prices), 2) if prices else None,113        }114    return best115116117@app.get("/healthz")118def healthz():119    return {"status": "ok"}120121122@app.get("/api/restaurants")123def list_restaurants(124    request: Request,125    region: str | None = None,126    city: str | None = None,127    cuisine: str | None = None,128    establishment_type: str | None = None,129    diet: str | None = None,             # vegan, sans-gluten…130    service: str | None = None,          # takeout, delivery, salle131    price_range: str | None = None,      # $ à $$$$132    chain: str | None = None,133    source: str | None = None,134    q: str | None = None,135    uids: str | None = None,             # liste d'uids séparés par des virgules136    has_menu: int | None = None,         # 1 = seulement les restos avec menu137    active: int = 1,138    sort: str = "menu",                  # menu | name | price | recent139    limit: int = Query(60, le=500),140    offset: int = 0,141):142    con = db.connect()143    sql = "SELECT * FROM restaurants WHERE dup_of IS NULL"   # doublons masqués144    args: list = []145    if active in (0, 1):146        sql += " AND active=?"; args.append(active)147    if region:148        sql += " AND region=?"; args.append(region)149    if city:150        sql += " AND city=?"; args.append(city)151    if cuisine:152        sql += " AND cuisines LIKE ?"; args.append(f'%"{cuisine}"%')153    if establishment_type:154        sql += " AND establishment_type=?"; args.append(establishment_type)155    if diet:156        sql += " AND dietary_options LIKE ?"; args.append(f'%"{diet}"%')157    if service:158        sql += " AND services LIKE ?"; args.append(f'%"{service}"%')159    if price_range:160        sql += " AND price_range=?"; args.append(price_range)161    if chain:162        sql += " AND chain=?"; args.append(chain)163    if source:164        sql += " AND source=?"; args.append(source)165    if q:166        sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)"167        args += [f"%{q}%"] * 4168    if uids:169        lst = [u.strip() for u in uids.split(",") if u.strip()][:24]170        if lst:171            sql += f" AND uid IN ({','.join('?' * len(lst))})"172            args += lst173    if has_menu == 1:174        sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"175    elif has_menu == 0:176        sql += " AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"177    total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]178    if sort == "recent":179        sql += " ORDER BY updated_at DESC"180    elif sort == "price":181        sql += " ORDER BY price_range='' , price_range ASC, name ASC"182    elif sort == "name":183        sql += " ORDER BY name COLLATE NOCASE ASC"184    else:   # défaut « menu » : les restos avec menu complet d'abord185        sql += (" ORDER BY NOT EXISTS (SELECT 1 FROM menus m"186                " WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC")187    sql += " LIMIT ? OFFSET ?"188    args += [limit, offset]189    rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]190    menus = _menu_summaries(con, [r["uid"] for r in rows])191    for r in rows:192        r["menu_summary"] = menus.get(r["uid"])193    con.close()194    # personnalisation KA ID : journal + reclassement (tris par défaut « menu »195    # et « recent » ; jamais nom/prix explicites ; session > long terme)196    personalized = False197    user = auth.current_user(request)198    if user and not uids:199        filters = {k: v for k, v in {200            "region": region, "city": city, "cuisine": cuisine,201            "establishment_type": establishment_type, "diet": diet,202            "price_range": price_range, "chain": chain, "q": q}.items()203            if v not in (None, "")}204        if filters and offset == 0:205            kaid.track(user, "search", query=q, filters=filters)206        if sort in ("menu", "recent"):207            active = {k for k in ("region", "city", "cuisine",208                                  "establishment_type", "price_range", "chain")209                      if filters.get(k)}210            rows, personalized = kaid.rerank(211                rows, user, features_of=_kaid_features, active_dims=active)212    return {"total": total, "count": len(rows), "restaurants": rows,213            "personalized": personalized}214215216@app.get("/api/dishes")217def search_dishes(218    q: str = Query(..., min_length=2),219    region: str | None = None,220    city: str | None = None,221    cuisine: str | None = None,222    price_max: float | None = None,223    limit: int = Query(30, le=100),224    offset: int = 0,225):226    """Recherche PAR PLAT dans tous les menus (nom + description).227228    Les succursales d'une même chaîne partagent le même menu : les résultats229    sont regroupés par (marque, plat, prix, contexte) avec le nombre230    d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.231    """232    con = db.connect()233    needle = q.strip().lower()234    sql = ("SELECT m.uid, m.price_context, m.price_source, m.captured_at,"235           " m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines"236           " FROM menus m JOIN restaurants r ON r.uid = m.uid"237           " WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?")238    args: list = [f"%{needle}%"]239    if region:240        sql += " AND r.region=?"; args.append(region)241    if city:242        sql += " AND r.city=?"; args.append(city)243    if cuisine:244        sql += " AND r.cuisines LIKE ?"; args.append(f'%"{cuisine}"%')245246    groups: dict[tuple, dict] = {}247    for row in con.execute(sql, args):248        try:249            sections = json.loads(row["sections"] or "[]")250        except ValueError:251            continue252        brand = row["chain"] or row["rname"]253        for sec in sections:254            for it in sec.get("items") or []:255                hay = f"{it.get('name', '')} {it.get('description', '')}".lower()256                if needle not in hay:257                    continue258                price = it.get("price")259                if price_max is not None and (price is None or price > price_max):260                    continue261                key = (brand, it.get("name"), price, row["price_context"])262                g = groups.get(key)263                if g is None:264                    groups[key] = {265                        "item": it.get("name"),266                        "description": it.get("description") or "",267                        "price": price,268                        "currency": it.get("currency", "CAD"),269                        "image": it.get("image"),270                        "tags": it.get("tags") or [],271                        "section": sec.get("name"),272                        "price_context": row["price_context"],273                        "price_source": row["price_source"],274                        "captured_at": row["captured_at"],275                        "brand": brand,276                        "restaurant": row["rname"],277                        "uid": row["uid"],278                        "city": row["city"],279                        "region": row["region"],280                        "locations": 1,281                    }282                else:283                    g["locations"] += 1284    con.close()285    results = sorted(groups.values(),286                     key=lambda g: (g["price"] is None, g["price"] or 0))287    total = len(results)288    return {"total": total, "count": min(limit, max(0, total - offset)),289            "dishes": results[offset:offset + limit]}290291292@app.get("/api/restaurants/{uid:path}/prices")293def price_history(uid: str):294    """Historique des prix par item (série temporelle, détection des hausses)."""295    con = db.connect()296    rows = [dict(r) for r in con.execute(297        "SELECT price_context, item_key, ts, price FROM item_price_log"298        " WHERE uid=? ORDER BY ts DESC LIMIT 500", (uid,))]299    con.close()300    return {"uid": uid, "prices": rows}301302303@app.get("/api/restaurants/{uid:path}/inspections")304def restaurant_inspections(uid: str):305    """Condamnations MAPAQ croisées avec ce restaurant (inspections306    alimentaires — Données Québec, licence CC-BY 4.0). Croisement307    conservateur : voir restoka/inspections.py."""308    con = db.connect()309    if con.execute("SELECT 1 FROM restaurants WHERE uid=?",310                   (uid,)).fetchone() is None:311        con.close()312        raise HTTPException(404, "Restaurant introuvable")313    rows = [dict(r) for r in con.execute(314        "SELECT etablissement, exploitant, description, adresse,"315        " date_infraction, date_jugement, montant_amende, motif, matched_by"316        " FROM inspections WHERE uid=? ORDER BY date_jugement DESC", (uid,))]317    con.close()318    return {"uid": uid, "total": len(rows),319            "total_amendes": round(sum(r["montant_amende"] or 0 for r in rows), 2),320            "source": "MAPAQ — Condamnations des établissements alimentaires "321                      "(Données Québec, CC-BY 4.0)",322            "inspections": rows}323324325@app.get("/api/restaurants/{uid:path}")326def get_restaurant(uid: str, request: Request):327    con = db.connect()328    row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone()329    if row is None:330        con.close()331        raise HTTPException(404, "Restaurant introuvable")332    d = _row_to_dict(row)333    # tous les contextes de prix disponibles, meilleur en premier — un menu334    # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)335    menus = []336    for m in con.execute(337            "SELECT price_context, price_source, currency, captured_at,"338            " item_count, sections FROM menus WHERE uid=?", (uid,)):339        menus.append({340            "price_context": m["price_context"],341            "price_source": m["price_source"],342            "currency": m["currency"],343            "captured_at": m["captured_at"],344            "item_count": m["item_count"],345            "sections": json.loads(m["sections"] or "[]"),346        })347    menus.sort(key=lambda m: _CTX_ORDER.get(m["price_context"], 9))348    d["menus"] = menus349    # items dont le prix a changé récemment (crédibilité, §12.2)350    d["recent_price_changes"] = [dict(r) for r in con.execute(351        "SELECT price_context, item_key, ts, price FROM item_price_log"352        " WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)"353        " GROUP BY item_key HAVING COUNT(*) > 1"354        " ORDER BY ts DESC LIMIT 20", (uid, uid))]355    con.close()356    kaid.track(auth.current_user(request), "detail_view",357               entity_type="restaurant", entity_id=uid,358               features=_kaid_features(d))359    return d360361362@app.get("/api/facets")363def facets(region: str | None = None):364    """Valeurs distinctes pour construire les filtres du frontend."""365    con = db.connect()366    city_sql = ("SELECT DISTINCT city FROM restaurants"367                " WHERE active=1 AND dup_of IS NULL AND city<>''")368    city_args: list = []369    if region:370        city_sql += " AND region=?"371        city_args.append(region)372    cuisines: dict[str, int] = {}373    diets: dict[str, int] = {}374    for r in con.execute("SELECT cuisines, dietary_options FROM restaurants"375                         " WHERE active=1 AND dup_of IS NULL"):376        for c in json.loads(r["cuisines"] or "[]"):377            cuisines[c] = cuisines.get(c, 0) + 1378        for dd in json.loads(r["dietary_options"] or "[]"):379            diets[dd] = diets.get(dd, 0) + 1380    out = {381        "regions": [dict(r) for r in con.execute(382            "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"383            " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")],384        "all_regions": REGIONS,385        "cities": [r["city"] for r in con.execute(386            city_sql + " ORDER BY city", city_args)],387        "cuisines": [{"cuisine": c, "n": n} for c, n in388                     sorted(cuisines.items(), key=lambda kv: -kv[1])],389        "diets": [{"diet": d, "n": n} for d, n in390                  sorted(diets.items(), key=lambda kv: -kv[1])],391        "establishment_types": [dict(r) for r in con.execute(392            "SELECT establishment_type t, COUNT(*) n FROM restaurants"393            " WHERE active=1 AND dup_of IS NULL AND establishment_type<>''"394            " GROUP BY establishment_type ORDER BY n DESC")],395        "chains": [r["chain"] for r in con.execute(396            "SELECT DISTINCT chain FROM restaurants WHERE active=1"397            " AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain")],398        "sources": [dict(r) for r in con.execute(399            "SELECT source, COUNT(*) n FROM restaurants WHERE active=1"400            " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],401    }402    con.close()403    return out404405406@app.get("/api/sources")407def sources():408    registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]409    con = db.connect()410    counts = {r["source"]: r["n"] for r in con.execute(411        "SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source")}412    items = {r["source"]: r["n"] for r in con.execute(413        "SELECT r.source, SUM(m.item_count) n FROM menus m"414        " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source")}415    last = {r["source"]: r["ts"] for r in con.execute(416        "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}417    con.close()418    for s in registry:419        s["active_restaurants"] = counts.get(s["id"], 0)420        s["menu_items"] = items.get(s["id"], 0)421        s["last_sync"] = last.get(s["id"])422        # ne pas exposer les clés d'intégration dans l'API publique423        if "integrations" in s:424            s["integrations"] = [{"label": i.get("label"),425                                  "status": i.get("status", "actif")}426                                 for i in s["integrations"]]427    return {"sources": registry}428429430# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------431_SITE = {432    "wordmark": "Resto·Ka",433    "accent": "#f08c00",            # safran — on-accent encre #141814434    "domain": "www.resto-ka.com",435    "tagline": "Chaque resto, chaque plat, chaque prix.",436}437438439@app.get("/api/stats/dashboard")440def stats_dashboard(441    period: str = "30j",442    from_: str | None = Query(None, alias="from"),443    to: str | None = Query(None),444):445    """Tableau de bord analytique (contrat commun Groupe KA, cache 5 min)."""446    from . import stats as kastats447    if period not in kastats.PERIOD_LABELS and not (from_ and to):448        raise HTTPException(400, "période inconnue")449    return kastats.dashboard(period, from_, to)450451452@app.get("/api/stats/report")453def stats_report(454    period: str = "30j",455    from_: str | None = Query(None, alias="from"),456    to: str | None = Query(None),457    mode: str = "complet",458):459    """Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) :460    complet | synthese | tendances | repartitions | donnees.461    Un mode inconnu retombe sur complet (rétrocompatible v1)."""462    from . import kapdf463    from . import stats as kastats464    if mode not in kapdf.REPORT_MODES:465        mode = "complet"466    if period not in kastats.PERIOD_LABELS and not (from_ and to):467        raise HTTPException(400, "période inconnue")468    dash = kastats.dashboard(period, from_, to)469    pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()470    fname = kapdf.filename("resto-ka", period, mode)471    return Response(content=pdf, media_type="application/pdf",472                    headers={"Content-Disposition":473                             f'attachment; filename="{fname}"'})474475476@app.get("/api/stats/catalog")477def stats_catalog(478    period: str = "30j",479    from_: str | None = Query(None, alias="from"),480    to: str | None = Query(None),481):482    """v3 — blocs composables pour le constructeur de rapports personnalisés."""483    from . import kapdf484    from . import stats as kastats485    if period not in kastats.PERIOD_LABELS and not (from_ and to):486        period = "30j"487    dash = kastats.dashboard(period, from_, to)488    return {"updated": dash.get("updated"), "period": dash.get("period"),489            "blocks": kapdf.catalog(dash)}490491492@app.post("/api/stats/report/custom")493def stats_report_custom(spec: dict = Body(...)):494    """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",495    "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""496    from . import kapdf497    from . import stats as kastats498    period = str(spec.get("period") or "30j")499    from_ = str(spec.get("from") or "") or None500    to = str(spec.get("to") or "") or None501    if period not in kastats.PERIOD_LABELS and not (from_ and to):502        period = "30j"503    dash = kastats.dashboard(period, from_, to)504    known = {b["key"] for b in kapdf.catalog(dash)}505    blocks = [b for b in (spec.get("blocks") or [])506              if isinstance(b, dict) and b.get("key") in known][:40]507    if not blocks:508        raise HTTPException(400, "Aucun bloc valide dans la composition")509    pdf = kapdf.GroupeKAReport(510        site=_SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,511        spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},512    ).build()513    fname = kapdf.filename("resto-ka", period, kapdf.CUSTOM_MODE)514    return Response(content=pdf, media_type="application/pdf",515                    headers={"Content-Disposition":516                             f'attachment; filename="{fname}"'})517518519@app.get("/api/stats")520def stats():521    con = db.connect()522    head = dict(con.execute(523        """SELECT COUNT(*) restaurants,524                  SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))525                      with_menu,526                  COUNT(DISTINCT chain) chains,527                  COUNT(DISTINCT region) regions,528                  COUNT(DISTINCT source) sources529           FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())530    m = dict(con.execute(531        """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items532           FROM menus m JOIN restaurants r ON r.uid=m.uid533           WHERE r.active=1 AND r.dup_of IS NULL""").fetchone())534    by_region = [dict(r) for r in con.execute(535        "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"536        " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")]537    by_context = [dict(r) for r in con.execute(538        "SELECT m.price_context, COUNT(*) n FROM menus m"539        " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1"540        " GROUP BY m.price_context")]541    log = [dict(r) for r in con.execute(542        "SELECT source, ts, found, added, updated, removed, ok, message"543        " FROM sync_log ORDER BY ts DESC LIMIT 20")]544    insp = dict(con.execute(545        """SELECT COUNT(*) inspections,546                  SUM(uid IS NOT NULL) inspections_matched,547                  COUNT(DISTINCT uid) restaurants_with_inspections548           FROM inspections""").fetchone())549    enr = dict(con.execute(550        """SELECT SUM(details LIKE '%"yelp":%') with_yelp_rating,551                  SUM(details LIKE '%"ubereats":%') with_ubereats,552                  SUM(details LIKE '%"permis_alcool"%') with_alcohol_permit553           FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())554    con.close()555    try:  # nb de sources au registre (data/sources.json), actives ou en attente556        registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]557        registered = len(registry)558    except (ValueError, OSError, KeyError):559        registered = None560    return {**head, **m, **insp, **enr, "sources_registry": registered,561            "by_region": by_region, "by_context": by_context,562            "recent_syncs": log}563564565@app.post("/api/sync")566def trigger_sync(background: BackgroundTasks, source: str | None = None):567    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""568    def _job():569        with _sync_lock:570            ingest.run([source] if source else None)571            ingest.enrich()572    background.add_task(_job)573    return {"status": "démarré", "source": source or "toutes"}574575576# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----577@app.get("/api/favorites")578def favorites(request: Request):579    """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage580    local). Retourne aussi les fiches locales correspondantes (ordre du hub)581    pour l'affichage de la page /favoris."""582    user = auth.current_user(request)583    if not user:584        raise HTTPException(401, "Connexion KA ID requise")585    items = hubfav.hub_list(user.get("ka_id") or "")586    if items is None:587        raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")588    ids = [i["item_id"] for i in items if i.get("item_id")]589    restos: list[dict] = []590    if ids:591        con = db.connect()592        marks = ",".join("?" * len(ids))593        rows = {r["uid"]: _row_to_dict(r) for r in con.execute(594            f"SELECT * FROM restaurants WHERE uid IN ({marks})",595            ids).fetchall()}596        menus = _menu_summaries(con, list(rows))597        con.close()598        for uid in ids:599            r = rows.get(uid)600            if r is not None:601                r["menu_summary"] = menus.get(uid)602                restos.append(r)603    return {"ids": ids, "items": items, "restaurants": restos}604605606@app.post("/api/favorites/toggle")607def toggle_favorite(request: Request, body: dict = Body(...)):608    """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub609    Groupe KA de façon synchrone : le hub est la seule source de vérité."""610    user = auth.current_user(request)611    if not user:612        raise HTTPException(401, "Connexion KA ID requise")613    ka_id = user.get("ka_id") or ""614    if not hubfav.linked(ka_id):615        raise HTTPException(403, "Compte non relié au hub Groupe KA")616    on = bool(body.get("on"))617    item = hubfav.clean_item(body.get("item") or {})618    if not item.get("item_id"):619        raise HTTPException(422, "item.item_id requis")620    if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):621        raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")622    # signal fort du moteur de préférences KA ID (features lues de la BD)623    con = db.connect()624    row = con.execute("SELECT * FROM restaurants WHERE uid=?",625                      (item["item_id"],)).fetchone()626    con.close()627    kaid.track(user, "favorite" if on else "unfavorite",628               entity_type="restaurant", entity_id=item["item_id"],629               features=_kaid_features(_row_to_dict(row)) if row else None)630    return {"ok": True, "on": on}631632633# --- Frontend React (build Vite) --------------------------------------------634if FRONTEND_DIST.exists():635    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"),636              name="assets")637638    @app.middleware("http")639    async def _cache_headers(request, call_next):640        """Bundles hachés immuables ; index.html toujours revalidé."""641        resp = await call_next(request)642        path = request.url.path643        if path.startswith("/assets/"):644            resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"645        elif "text/html" in (resp.headers.get("content-type") or ""):646            resp.headers["Cache-Control"] = "no-cache"647        return resp648649    # Documentation utilisateur (frontend/public/doc → dist/doc) — routes650    # explicites AVANT le catch-all SPA pour servir /doc/ comme un index.651    @app.get("/doc")652    def doc_redirect():653        return RedirectResponse("/doc/", status_code=308)654655    @app.get("/doc/")656    def doc_index():657        return FileResponse(FRONTEND_DIST / "doc" / "index.html")658659    _CLIENT_PREFIXES = ("resto/", "sources", "stats", "favoris", "contact")660661    @app.get("/{full_path:path}")662    def spa(full_path: str):663        target = FRONTEND_DIST / full_path664        if full_path and target.is_file():665            return FileResponse(target)666        known = (full_path == "" or full_path in _CLIENT_PREFIXES667                 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))668        return FileResponse(FRONTEND_DIST / "index.html",669                            status_code=200 if known else 404,670                            headers={"Cache-Control": "no-cache"})671