Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
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, ingest23from .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# référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py46# inclus AVANT le fallback SPA pour que ces routes aient priorité47from . import seo # noqa: E40248app.include_router(seo.router)495051def _row_to_dict(row) -> dict:52 d = dict(row)53 for col in ("cuisines", "services", "dietary_options", "languages", "images"):54 d[col] = json.loads(d.get(col) or "[]")55 d["hours"] = json.loads(d.get("hours") or "{}")56 try: # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details57 d["details"] = json.loads(d.get("details") or "{}")58 except (ValueError, TypeError):59 d["details"] = {}60 if d.get("dup_sources"):61 try:62 d["dup_sources"] = json.loads(d["dup_sources"])63 except (ValueError, TypeError):64 d["dup_sources"] = []65 return d666768def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:69 """Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes."""70 if not uids:71 return {}72 qmarks = ",".join("?" * len(uids))73 best: dict[str, dict] = {}74 for r in con.execute(75 f"SELECT uid, price_context, price_source, captured_at, item_count,"76 f" sections FROM menus WHERE uid IN ({qmarks})", uids):77 cur = best.get(r["uid"])78 if cur and _CTX_ORDER.get(cur["price_context"], 9) <= \79 _CTX_ORDER.get(r["price_context"], 9):80 continue81 sections = json.loads(r["sections"] or "[]")82 prices = [it.get("price")83 for sec in sections84 for it in sec.get("items") or []85 if isinstance(it.get("price"), (int, float)) and it["price"] > 0]86 image = next((it["image"] for sec in sections87 for it in sec.get("items") or [] if it.get("image")),88 next((sec["image"] for sec in sections89 if sec.get("image")), None))90 best[r["uid"]] = {91 "image": image,92 "price_context": r["price_context"],93 "price_source": r["price_source"],94 "captured_at": r["captured_at"],95 "item_count": r["item_count"],96 "price_min": min(prices) if prices else None,97 "price_median": round(statistics.median(prices), 2) if prices else None,98 }99 return best100101102@app.get("/healthz")103def healthz():104 return {"status": "ok"}105106107@app.get("/api/restaurants")108def list_restaurants(109 region: str | None = None,110 city: str | None = None,111 cuisine: str | None = None,112 establishment_type: str | None = None,113 diet: str | None = None, # vegan, sans-gluten…114 service: str | None = None, # takeout, delivery, salle115 price_range: str | None = None, # $ à $$$$116 chain: str | None = None,117 source: str | None = None,118 q: str | None = None,119 uids: str | None = None, # liste d'uids séparés par des virgules120 has_menu: int | None = None, # 1 = seulement les restos avec menu121 active: int = 1,122 sort: str = "menu", # menu | name | price | recent123 limit: int = Query(60, le=500),124 offset: int = 0,125):126 con = db.connect()127 sql = "SELECT * FROM restaurants WHERE dup_of IS NULL" # doublons masqués128 args: list = []129 if active in (0, 1):130 sql += " AND active=?"; args.append(active)131 if region:132 sql += " AND region=?"; args.append(region)133 if city:134 sql += " AND city=?"; args.append(city)135 if cuisine:136 sql += " AND cuisines LIKE ?"; args.append(f'%"{cuisine}"%')137 if establishment_type:138 sql += " AND establishment_type=?"; args.append(establishment_type)139 if diet:140 sql += " AND dietary_options LIKE ?"; args.append(f'%"{diet}"%')141 if service:142 sql += " AND services LIKE ?"; args.append(f'%"{service}"%')143 if price_range:144 sql += " AND price_range=?"; args.append(price_range)145 if chain:146 sql += " AND chain=?"; args.append(chain)147 if source:148 sql += " AND source=?"; args.append(source)149 if q:150 sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)"151 args += [f"%{q}%"] * 4152 if uids:153 lst = [u.strip() for u in uids.split(",") if u.strip()][:24]154 if lst:155 sql += f" AND uid IN ({','.join('?' * len(lst))})"156 args += lst157 if has_menu == 1:158 sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"159 elif has_menu == 0:160 sql += " AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"161 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]162 if sort == "recent":163 sql += " ORDER BY updated_at DESC"164 elif sort == "price":165 sql += " ORDER BY price_range='' , price_range ASC, name ASC"166 elif sort == "name":167 sql += " ORDER BY name COLLATE NOCASE ASC"168 else: # défaut « menu » : les restos avec menu complet d'abord169 sql += (" ORDER BY NOT EXISTS (SELECT 1 FROM menus m"170 " WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC")171 sql += " LIMIT ? OFFSET ?"172 args += [limit, offset]173 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]174 menus = _menu_summaries(con, [r["uid"] for r in rows])175 for r in rows:176 r["menu_summary"] = menus.get(r["uid"])177 con.close()178 return {"total": total, "count": len(rows), "restaurants": rows}179180181@app.get("/api/dishes")182def search_dishes(183 q: str = Query(..., min_length=2),184 region: str | None = None,185 city: str | None = None,186 cuisine: str | None = None,187 price_max: float | None = None,188 limit: int = Query(30, le=100),189 offset: int = 0,190):191 """Recherche PAR PLAT dans tous les menus (nom + description).192193 Les succursales d'une même chaîne partagent le même menu : les résultats194 sont regroupés par (marque, plat, prix, contexte) avec le nombre195 d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.196 """197 con = db.connect()198 needle = q.strip().lower()199 sql = ("SELECT m.uid, m.price_context, m.price_source, m.captured_at,"200 " m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines"201 " FROM menus m JOIN restaurants r ON r.uid = m.uid"202 " WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?")203 args: list = [f"%{needle}%"]204 if region:205 sql += " AND r.region=?"; args.append(region)206 if city:207 sql += " AND r.city=?"; args.append(city)208 if cuisine:209 sql += " AND r.cuisines LIKE ?"; args.append(f'%"{cuisine}"%')210211 groups: dict[tuple, dict] = {}212 for row in con.execute(sql, args):213 try:214 sections = json.loads(row["sections"] or "[]")215 except ValueError:216 continue217 brand = row["chain"] or row["rname"]218 for sec in sections:219 for it in sec.get("items") or []:220 hay = f"{it.get('name', '')} {it.get('description', '')}".lower()221 if needle not in hay:222 continue223 price = it.get("price")224 if price_max is not None and (price is None or price > price_max):225 continue226 key = (brand, it.get("name"), price, row["price_context"])227 g = groups.get(key)228 if g is None:229 groups[key] = {230 "item": it.get("name"),231 "description": it.get("description") or "",232 "price": price,233 "currency": it.get("currency", "CAD"),234 "image": it.get("image"),235 "tags": it.get("tags") or [],236 "section": sec.get("name"),237 "price_context": row["price_context"],238 "price_source": row["price_source"],239 "captured_at": row["captured_at"],240 "brand": brand,241 "restaurant": row["rname"],242 "uid": row["uid"],243 "city": row["city"],244 "region": row["region"],245 "locations": 1,246 }247 else:248 g["locations"] += 1249 con.close()250 results = sorted(groups.values(),251 key=lambda g: (g["price"] is None, g["price"] or 0))252 total = len(results)253 return {"total": total, "count": min(limit, max(0, total - offset)),254 "dishes": results[offset:offset + limit]}255256257@app.get("/api/restaurants/{uid:path}/prices")258def price_history(uid: str):259 """Historique des prix par item (série temporelle, détection des hausses)."""260 con = db.connect()261 rows = [dict(r) for r in con.execute(262 "SELECT price_context, item_key, ts, price FROM item_price_log"263 " WHERE uid=? ORDER BY ts DESC LIMIT 500", (uid,))]264 con.close()265 return {"uid": uid, "prices": rows}266267268@app.get("/api/restaurants/{uid:path}/inspections")269def restaurant_inspections(uid: str):270 """Condamnations MAPAQ croisées avec ce restaurant (inspections271 alimentaires — Données Québec, licence CC-BY 4.0). Croisement272 conservateur : voir restoka/inspections.py."""273 con = db.connect()274 if con.execute("SELECT 1 FROM restaurants WHERE uid=?",275 (uid,)).fetchone() is None:276 con.close()277 raise HTTPException(404, "Restaurant introuvable")278 rows = [dict(r) for r in con.execute(279 "SELECT etablissement, exploitant, description, adresse,"280 " date_infraction, date_jugement, montant_amende, motif, matched_by"281 " FROM inspections WHERE uid=? ORDER BY date_jugement DESC", (uid,))]282 con.close()283 return {"uid": uid, "total": len(rows),284 "total_amendes": round(sum(r["montant_amende"] or 0 for r in rows), 2),285 "source": "MAPAQ — Condamnations des établissements alimentaires "286 "(Données Québec, CC-BY 4.0)",287 "inspections": rows}288289290@app.get("/api/restaurants/{uid:path}")291def get_restaurant(uid: str):292 con = db.connect()293 row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone()294 if row is None:295 con.close()296 raise HTTPException(404, "Restaurant introuvable")297 d = _row_to_dict(row)298 # tous les contextes de prix disponibles, meilleur en premier — un menu299 # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)300 menus = []301 for m in con.execute(302 "SELECT price_context, price_source, currency, captured_at,"303 " item_count, sections FROM menus WHERE uid=?", (uid,)):304 menus.append({305 "price_context": m["price_context"],306 "price_source": m["price_source"],307 "currency": m["currency"],308 "captured_at": m["captured_at"],309 "item_count": m["item_count"],310 "sections": json.loads(m["sections"] or "[]"),311 })312 menus.sort(key=lambda m: _CTX_ORDER.get(m["price_context"], 9))313 d["menus"] = menus314 # items dont le prix a changé récemment (crédibilité, §12.2)315 d["recent_price_changes"] = [dict(r) for r in con.execute(316 "SELECT price_context, item_key, ts, price FROM item_price_log"317 " WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)"318 " GROUP BY item_key HAVING COUNT(*) > 1"319 " ORDER BY ts DESC LIMIT 20", (uid, uid))]320 con.close()321 return d322323324@app.get("/api/facets")325def facets(region: str | None = None):326 """Valeurs distinctes pour construire les filtres du frontend."""327 con = db.connect()328 city_sql = ("SELECT DISTINCT city FROM restaurants"329 " WHERE active=1 AND dup_of IS NULL AND city<>''")330 city_args: list = []331 if region:332 city_sql += " AND region=?"333 city_args.append(region)334 cuisines: dict[str, int] = {}335 diets: dict[str, int] = {}336 for r in con.execute("SELECT cuisines, dietary_options FROM restaurants"337 " WHERE active=1 AND dup_of IS NULL"):338 for c in json.loads(r["cuisines"] or "[]"):339 cuisines[c] = cuisines.get(c, 0) + 1340 for dd in json.loads(r["dietary_options"] or "[]"):341 diets[dd] = diets.get(dd, 0) + 1342 out = {343 "regions": [dict(r) for r in con.execute(344 "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"345 " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")],346 "all_regions": REGIONS,347 "cities": [r["city"] for r in con.execute(348 city_sql + " ORDER BY city", city_args)],349 "cuisines": [{"cuisine": c, "n": n} for c, n in350 sorted(cuisines.items(), key=lambda kv: -kv[1])],351 "diets": [{"diet": d, "n": n} for d, n in352 sorted(diets.items(), key=lambda kv: -kv[1])],353 "establishment_types": [dict(r) for r in con.execute(354 "SELECT establishment_type t, COUNT(*) n FROM restaurants"355 " WHERE active=1 AND dup_of IS NULL AND establishment_type<>''"356 " GROUP BY establishment_type ORDER BY n DESC")],357 "chains": [r["chain"] for r in con.execute(358 "SELECT DISTINCT chain FROM restaurants WHERE active=1"359 " AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain")],360 "sources": [dict(r) for r in con.execute(361 "SELECT source, COUNT(*) n FROM restaurants WHERE active=1"362 " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],363 }364 con.close()365 return out366367368@app.get("/api/sources")369def sources():370 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]371 con = db.connect()372 counts = {r["source"]: r["n"] for r in con.execute(373 "SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source")}374 items = {r["source"]: r["n"] for r in con.execute(375 "SELECT r.source, SUM(m.item_count) n FROM menus m"376 " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source")}377 last = {r["source"]: r["ts"] for r in con.execute(378 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}379 con.close()380 for s in registry:381 s["active_restaurants"] = counts.get(s["id"], 0)382 s["menu_items"] = items.get(s["id"], 0)383 s["last_sync"] = last.get(s["id"])384 # ne pas exposer les clés d'intégration dans l'API publique385 if "integrations" in s:386 s["integrations"] = [{"label": i.get("label"),387 "status": i.get("status", "actif")}388 for i in s["integrations"]]389 return {"sources": registry}390391392# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------393_SITE = {394 "wordmark": "Resto·Ka",395 "accent": "#f08c00", # safran — on-accent encre #141814396 "domain": "www.resto-ka.com",397 "tagline": "Chaque resto, chaque plat, chaque prix.",398}399400401@app.get("/api/stats/dashboard")402def stats_dashboard(403 period: str = "30j",404 from_: str | None = Query(None, alias="from"),405 to: str | None = Query(None),406):407 """Tableau de bord analytique (contrat commun Groupe KA, cache 5 min)."""408 from . import stats as kastats409 if period not in kastats.PERIOD_LABELS and not (from_ and to):410 raise HTTPException(400, "période inconnue")411 return kastats.dashboard(period, from_, to)412413414@app.get("/api/stats/report")415def stats_report(416 period: str = "30j",417 from_: str | None = Query(None, alias="from"),418 to: str | None = Query(None),419 mode: str = "complet",420):421 """Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) :422 complet | synthese | tendances | repartitions | donnees.423 Un mode inconnu retombe sur complet (rétrocompatible v1)."""424 from . import kapdf425 from . import stats as kastats426 if mode not in kapdf.REPORT_MODES:427 mode = "complet"428 if period not in kastats.PERIOD_LABELS and not (from_ and to):429 raise HTTPException(400, "période inconnue")430 dash = kastats.dashboard(period, from_, to)431 pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()432 fname = kapdf.filename("resto-ka", period, mode)433 return Response(content=pdf, media_type="application/pdf",434 headers={"Content-Disposition":435 f'attachment; filename="{fname}"'})436437438@app.get("/api/stats/catalog")439def stats_catalog(440 period: str = "30j",441 from_: str | None = Query(None, alias="from"),442 to: str | None = Query(None),443):444 """v3 — blocs composables pour le constructeur de rapports personnalisés."""445 from . import kapdf446 from . import stats as kastats447 if period not in kastats.PERIOD_LABELS and not (from_ and to):448 period = "30j"449 dash = kastats.dashboard(period, from_, to)450 return {"updated": dash.get("updated"), "period": dash.get("period"),451 "blocks": kapdf.catalog(dash)}452453454@app.post("/api/stats/report/custom")455def stats_report_custom(spec: dict = Body(...)):456 """v3 — rapport PDF personnalisé : {"title", "period", "from", "to",457 "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis)."""458 from . import kapdf459 from . import stats as kastats460 period = str(spec.get("period") or "30j")461 from_ = str(spec.get("from") or "") or None462 to = str(spec.get("to") or "") or None463 if period not in kastats.PERIOD_LABELS and not (from_ and to):464 period = "30j"465 dash = kastats.dashboard(period, from_, to)466 known = {b["key"] for b in kapdf.catalog(dash)}467 blocks = [b for b in (spec.get("blocks") or [])468 if isinstance(b, dict) and b.get("key") in known][:40]469 if not blocks:470 raise HTTPException(400, "Aucun bloc valide dans la composition")471 pdf = kapdf.GroupeKAReport(472 site=_SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE,473 spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks},474 ).build()475 fname = kapdf.filename("resto-ka", period, kapdf.CUSTOM_MODE)476 return Response(content=pdf, media_type="application/pdf",477 headers={"Content-Disposition":478 f'attachment; filename="{fname}"'})479480481@app.get("/api/stats")482def stats():483 con = db.connect()484 head = dict(con.execute(485 """SELECT COUNT(*) restaurants,486 SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))487 with_menu,488 COUNT(DISTINCT chain) chains,489 COUNT(DISTINCT region) regions,490 COUNT(DISTINCT source) sources491 FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())492 m = dict(con.execute(493 """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items494 FROM menus m JOIN restaurants r ON r.uid=m.uid495 WHERE r.active=1 AND r.dup_of IS NULL""").fetchone())496 by_region = [dict(r) for r in con.execute(497 "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"498 " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")]499 by_context = [dict(r) for r in con.execute(500 "SELECT m.price_context, COUNT(*) n FROM menus m"501 " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1"502 " GROUP BY m.price_context")]503 log = [dict(r) for r in con.execute(504 "SELECT source, ts, found, added, updated, removed, ok, message"505 " FROM sync_log ORDER BY ts DESC LIMIT 20")]506 insp = dict(con.execute(507 """SELECT COUNT(*) inspections,508 SUM(uid IS NOT NULL) inspections_matched,509 COUNT(DISTINCT uid) restaurants_with_inspections510 FROM inspections""").fetchone())511 enr = dict(con.execute(512 """SELECT SUM(details LIKE '%"yelp":%') with_yelp_rating,513 SUM(details LIKE '%"ubereats":%') with_ubereats,514 SUM(details LIKE '%"permis_alcool"%') with_alcohol_permit515 FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())516 con.close()517 try: # nb de sources au registre (data/sources.json), actives ou en attente518 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]519 registered = len(registry)520 except (ValueError, OSError, KeyError):521 registered = None522 return {**head, **m, **insp, **enr, "sources_registry": registered,523 "by_region": by_region, "by_context": by_context,524 "recent_syncs": log}525526527@app.post("/api/sync")528def trigger_sync(background: BackgroundTasks, source: str | None = None):529 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""530 def _job():531 with _sync_lock:532 ingest.run([source] if source else None)533 ingest.enrich()534 background.add_task(_job)535 return {"status": "démarré", "source": source or "toutes"}536537538# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----539@app.get("/api/favorites")540def favorites(request: Request):541 """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage542 local). Retourne aussi les fiches locales correspondantes (ordre du hub)543 pour l'affichage de la page /favoris."""544 user = auth.current_user(request)545 if not user:546 raise HTTPException(401, "Connexion KA ID requise")547 items = hubfav.hub_list(user.get("ka_id") or "")548 if items is None:549 raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")550 ids = [i["item_id"] for i in items if i.get("item_id")]551 restos: list[dict] = []552 if ids:553 con = db.connect()554 marks = ",".join("?" * len(ids))555 rows = {r["uid"]: _row_to_dict(r) for r in con.execute(556 f"SELECT * FROM restaurants WHERE uid IN ({marks})",557 ids).fetchall()}558 menus = _menu_summaries(con, list(rows))559 con.close()560 for uid in ids:561 r = rows.get(uid)562 if r is not None:563 r["menu_summary"] = menus.get(uid)564 restos.append(r)565 return {"ids": ids, "items": items, "restaurants": restos}566567568@app.post("/api/favorites/toggle")569def toggle_favorite(request: Request, body: dict = Body(...)):570 """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub571 Groupe KA de façon synchrone : le hub est la seule source de vérité."""572 user = auth.current_user(request)573 if not user:574 raise HTTPException(401, "Connexion KA ID requise")575 ka_id = user.get("ka_id") or ""576 if not hubfav.linked(ka_id):577 raise HTTPException(403, "Compte non relié au hub Groupe KA")578 on = bool(body.get("on"))579 item = hubfav.clean_item(body.get("item") or {})580 if not item.get("item_id"):581 raise HTTPException(422, "item.item_id requis")582 if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):583 raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")584 return {"ok": True, "on": on}585586587# --- Frontend React (build Vite) --------------------------------------------588if FRONTEND_DIST.exists():589 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"),590 name="assets")591592 @app.middleware("http")593 async def _cache_headers(request, call_next):594 """Bundles hachés immuables ; index.html toujours revalidé."""595 resp = await call_next(request)596 path = request.url.path597 if path.startswith("/assets/"):598 resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"599 elif "text/html" in (resp.headers.get("content-type") or ""):600 resp.headers["Cache-Control"] = "no-cache"601 return resp602603 # Documentation utilisateur (frontend/public/doc → dist/doc) — routes604 # explicites AVANT le catch-all SPA pour servir /doc/ comme un index.605 @app.get("/doc")606 def doc_redirect():607 return RedirectResponse("/doc/", status_code=308)608609 @app.get("/doc/")610 def doc_index():611 return FileResponse(FRONTEND_DIST / "doc" / "index.html")612613 _CLIENT_PREFIXES = ("resto/", "sources", "stats", "favoris", "contact")614615 @app.get("/{full_path:path}")616 def spa(full_path: str):617 target = FRONTEND_DIST / full_path618 if full_path and target.is_file():619 return FileResponse(target)620 known = (full_path == "" or full_path in _CLIENT_PREFIXES621 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))622 return FileResponse(FRONTEND_DIST / "index.html",623 status_code=200 if known else 404,624 headers={"Cache-Control": "no-cache"})625