Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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