# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/web.py # Desc: API FastAPI (JSON) + service du frontend React (frontend/dist). # Recherche des restaurants (région, ville, cuisine, diète, prix, texte), # fiche complète avec menus PAR CONTEXTE de prix (dine-in > takeout > # delivery, toujours étiqueté) et historique des prix. Calqué louka/web.py. # ============================================================================== from __future__ import annotations import json import statistics import threading from pathlib import Path from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from . import db, hubfav, ingest, kaid from .normalize import PRICE_CONTEXTS from .regions import REGIONS ROOT = Path(__file__).resolve().parent.parent SOURCES_PATH = ROOT / "data" / "sources.json" FRONTEND_DIST = ROOT / "frontend" / "dist" # préférence de contexte : dine-in > takeout > delivery (CLAUDE.md §6.2) _CTX_ORDER = {c: i for i, c in enumerate(PRICE_CONTEXTS)} app = FastAPI(title="Resto-Ka API", version="1.0", description="Agrégateur des restaurants du Québec — menus & prix") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.add_middleware(GZipMiddleware, minimum_size=1000) _sync_lock = threading.Lock() # comptes membres (« Se connecter avec KA ») + favoris — voir restoka/auth.py from . import auth # noqa: E402 (import tardif : évite le cycle web<->auth) app.include_router(auth.router) # personnalisation KA ID v2 (restoka/kaid.py, canonique ka-ui.git/kaid) kaid.init("resto-ka") app.include_router(kaid.build_router(auth.current_user)) def _kaid_features(d: dict) -> dict: """Caractéristiques d'un restaurant pour le profil de préférences KA ID.""" return {k: v for k, v in { "city": d.get("city"), "region": d.get("region"), "cuisine": d.get("cuisines") or None, "establishment_type": d.get("establishment_type"), "price_range": d.get("price_range") or None, "chain": d.get("chain") or None, }.items() if v not in (None, "", [])} # référencement : SSR léger (accueil, fiches), robots.txt, sitemaps — restoka/seo.py # inclus AVANT le fallback SPA pour que ces routes aient priorité from . import seo # noqa: E402 app.include_router(seo.router) def _row_to_dict(row) -> dict: d = dict(row) for col in ("cuisines", "services", "dietary_options", "languages", "images"): d[col] = json.loads(d.get(col) or "[]") d["hours"] = json.loads(d.get("hours") or "{}") try: # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details d["details"] = json.loads(d.get("details") or "{}") except (ValueError, TypeError): d["details"] = {} if d.get("dup_sources"): try: d["dup_sources"] = json.loads(d["dup_sources"]) except (ValueError, TypeError): d["dup_sources"] = [] return d def _menu_summaries(con, uids: list[str]) -> dict[str, dict]: """Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes.""" if not uids: return {} qmarks = ",".join("?" * len(uids)) best: dict[str, dict] = {} for r in con.execute( f"SELECT uid, price_context, price_source, captured_at, item_count," f" sections FROM menus WHERE uid IN ({qmarks})", uids): cur = best.get(r["uid"]) if cur and _CTX_ORDER.get(cur["price_context"], 9) <= \ _CTX_ORDER.get(r["price_context"], 9): continue sections = json.loads(r["sections"] or "[]") prices = [it.get("price") for sec in sections for it in sec.get("items") or [] if isinstance(it.get("price"), (int, float)) and it["price"] > 0] image = next((it["image"] for sec in sections for it in sec.get("items") or [] if it.get("image")), next((sec["image"] for sec in sections if sec.get("image")), None)) best[r["uid"]] = { "image": image, "price_context": r["price_context"], "price_source": r["price_source"], "captured_at": r["captured_at"], "item_count": r["item_count"], "price_min": min(prices) if prices else None, "price_median": round(statistics.median(prices), 2) if prices else None, } return best @app.get("/healthz") def healthz(): return {"status": "ok"} @app.get("/api/restaurants") def list_restaurants( request: Request, region: str | None = None, city: str | None = None, cuisine: str | None = None, establishment_type: str | None = None, diet: str | None = None, # vegan, sans-gluten… service: str | None = None, # takeout, delivery, salle price_range: str | None = None, # $ à $$$$ chain: str | None = None, source: str | None = None, q: str | None = None, uids: str | None = None, # liste d'uids séparés par des virgules has_menu: int | None = None, # 1 = seulement les restos avec menu active: int = 1, sort: str = "menu", # menu | name | price | recent limit: int = Query(60, le=500), offset: int = 0, ): con = db.connect() sql = "SELECT * FROM restaurants WHERE dup_of IS NULL" # doublons masqués args: list = [] if active in (0, 1): sql += " AND active=?"; args.append(active) if region: sql += " AND region=?"; args.append(region) if city: sql += " AND city=?"; args.append(city) if cuisine: sql += " AND cuisines LIKE ?"; args.append(f'%"{cuisine}"%') if establishment_type: sql += " AND establishment_type=?"; args.append(establishment_type) if diet: sql += " AND dietary_options LIKE ?"; args.append(f'%"{diet}"%') if service: sql += " AND services LIKE ?"; args.append(f'%"{service}"%') if price_range: sql += " AND price_range=?"; args.append(price_range) if chain: sql += " AND chain=?"; args.append(chain) if source: sql += " AND source=?"; args.append(source) if q: sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)" args += [f"%{q}%"] * 4 if uids: lst = [u.strip() for u in uids.split(",") if u.strip()][:24] if lst: sql += f" AND uid IN ({','.join('?' * len(lst))})" args += lst if has_menu == 1: sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" elif has_menu == 0: sql += " AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] if sort == "recent": sql += " ORDER BY updated_at DESC" elif sort == "price": sql += " ORDER BY price_range='' , price_range ASC, name ASC" elif sort == "name": sql += " ORDER BY name COLLATE NOCASE ASC" else: # défaut « menu » : les restos avec menu complet d'abord sql += (" ORDER BY NOT EXISTS (SELECT 1 FROM menus m" " WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC") sql += " LIMIT ? OFFSET ?" args += [limit, offset] rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] menus = _menu_summaries(con, [r["uid"] for r in rows]) for r in rows: r["menu_summary"] = menus.get(r["uid"]) con.close() # personnalisation KA ID : journal + reclassement (tris par défaut « menu » # et « recent » ; jamais nom/prix explicites ; session > long terme) personalized = False user = auth.current_user(request) if user and not uids: filters = {k: v for k, v in { "region": region, "city": city, "cuisine": cuisine, "establishment_type": establishment_type, "diet": diet, "price_range": price_range, "chain": chain, "q": q}.items() if v not in (None, "")} if filters and offset == 0: kaid.track(user, "search", query=q, filters=filters) if sort in ("menu", "recent"): active = {k for k in ("region", "city", "cuisine", "establishment_type", "price_range", "chain") if filters.get(k)} rows, personalized = kaid.rerank( rows, user, features_of=_kaid_features, active_dims=active) return {"total": total, "count": len(rows), "restaurants": rows, "personalized": personalized} @app.get("/api/dishes") def search_dishes( q: str = Query(..., min_length=2), region: str | None = None, city: str | None = None, cuisine: str | None = None, price_max: float | None = None, limit: int = Query(30, le=100), offset: int = 0, ): """Recherche PAR PLAT dans tous les menus (nom + description). Les succursales d'une même chaîne partagent le même menu : les résultats sont regroupés par (marque, plat, prix, contexte) avec le nombre d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques. """ con = db.connect() needle = q.strip().lower() sql = ("SELECT m.uid, m.price_context, m.price_source, m.captured_at," " m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines" " FROM menus m JOIN restaurants r ON r.uid = m.uid" " WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?") args: list = [f"%{needle}%"] if region: sql += " AND r.region=?"; args.append(region) if city: sql += " AND r.city=?"; args.append(city) if cuisine: sql += " AND r.cuisines LIKE ?"; args.append(f'%"{cuisine}"%') groups: dict[tuple, dict] = {} for row in con.execute(sql, args): try: sections = json.loads(row["sections"] or "[]") except ValueError: continue brand = row["chain"] or row["rname"] for sec in sections: for it in sec.get("items") or []: hay = f"{it.get('name', '')} {it.get('description', '')}".lower() if needle not in hay: continue price = it.get("price") if price_max is not None and (price is None or price > price_max): continue key = (brand, it.get("name"), price, row["price_context"]) g = groups.get(key) if g is None: groups[key] = { "item": it.get("name"), "description": it.get("description") or "", "price": price, "currency": it.get("currency", "CAD"), "image": it.get("image"), "tags": it.get("tags") or [], "section": sec.get("name"), "price_context": row["price_context"], "price_source": row["price_source"], "captured_at": row["captured_at"], "brand": brand, "restaurant": row["rname"], "uid": row["uid"], "city": row["city"], "region": row["region"], "locations": 1, } else: g["locations"] += 1 con.close() results = sorted(groups.values(), key=lambda g: (g["price"] is None, g["price"] or 0)) total = len(results) return {"total": total, "count": min(limit, max(0, total - offset)), "dishes": results[offset:offset + limit]} @app.get("/api/restaurants/{uid:path}/prices") def price_history(uid: str): """Historique des prix par item (série temporelle, détection des hausses).""" con = db.connect() rows = [dict(r) for r in con.execute( "SELECT price_context, item_key, ts, price FROM item_price_log" " WHERE uid=? ORDER BY ts DESC LIMIT 500", (uid,))] con.close() return {"uid": uid, "prices": rows} @app.get("/api/restaurants/{uid:path}/inspections") def restaurant_inspections(uid: str): """Condamnations MAPAQ croisées avec ce restaurant (inspections alimentaires — Données Québec, licence CC-BY 4.0). Croisement conservateur : voir restoka/inspections.py.""" con = db.connect() if con.execute("SELECT 1 FROM restaurants WHERE uid=?", (uid,)).fetchone() is None: con.close() raise HTTPException(404, "Restaurant introuvable") rows = [dict(r) for r in con.execute( "SELECT etablissement, exploitant, description, adresse," " date_infraction, date_jugement, montant_amende, motif, matched_by" " FROM inspections WHERE uid=? ORDER BY date_jugement DESC", (uid,))] con.close() return {"uid": uid, "total": len(rows), "total_amendes": round(sum(r["montant_amende"] or 0 for r in rows), 2), "source": "MAPAQ — Condamnations des établissements alimentaires " "(Données Québec, CC-BY 4.0)", "inspections": rows} @app.get("/api/restaurants/{uid:path}") def get_restaurant(uid: str, request: Request): con = db.connect() row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone() if row is None: con.close() raise HTTPException(404, "Restaurant introuvable") d = _row_to_dict(row) # tous les contextes de prix disponibles, meilleur en premier — un menu # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2) menus = [] for m in con.execute( "SELECT price_context, price_source, currency, captured_at," " item_count, sections FROM menus WHERE uid=?", (uid,)): menus.append({ "price_context": m["price_context"], "price_source": m["price_source"], "currency": m["currency"], "captured_at": m["captured_at"], "item_count": m["item_count"], "sections": json.loads(m["sections"] or "[]"), }) menus.sort(key=lambda m: _CTX_ORDER.get(m["price_context"], 9)) d["menus"] = menus # items dont le prix a changé récemment (crédibilité, §12.2) d["recent_price_changes"] = [dict(r) for r in con.execute( "SELECT price_context, item_key, ts, price FROM item_price_log" " WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)" " GROUP BY item_key HAVING COUNT(*) > 1" " ORDER BY ts DESC LIMIT 20", (uid, uid))] con.close() kaid.track(auth.current_user(request), "detail_view", entity_type="restaurant", entity_id=uid, features=_kaid_features(d)) return d @app.get("/api/facets") def facets(region: str | None = None): """Valeurs distinctes pour construire les filtres du frontend.""" con = db.connect() city_sql = ("SELECT DISTINCT city FROM restaurants" " WHERE active=1 AND dup_of IS NULL AND city<>''") city_args: list = [] if region: city_sql += " AND region=?" city_args.append(region) cuisines: dict[str, int] = {} diets: dict[str, int] = {} for r in con.execute("SELECT cuisines, dietary_options FROM restaurants" " WHERE active=1 AND dup_of IS NULL"): for c in json.loads(r["cuisines"] or "[]"): cuisines[c] = cuisines.get(c, 0) + 1 for dd in json.loads(r["dietary_options"] or "[]"): diets[dd] = diets.get(dd, 0) + 1 out = { "regions": [dict(r) for r in con.execute( "SELECT region, COUNT(*) n FROM restaurants WHERE active=1" " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")], "all_regions": REGIONS, "cities": [r["city"] for r in con.execute( city_sql + " ORDER BY city", city_args)], "cuisines": [{"cuisine": c, "n": n} for c, n in sorted(cuisines.items(), key=lambda kv: -kv[1])], "diets": [{"diet": d, "n": n} for d, n in sorted(diets.items(), key=lambda kv: -kv[1])], "establishment_types": [dict(r) for r in con.execute( "SELECT establishment_type t, COUNT(*) n FROM restaurants" " WHERE active=1 AND dup_of IS NULL AND establishment_type<>''" " GROUP BY establishment_type ORDER BY n DESC")], "chains": [r["chain"] for r in con.execute( "SELECT DISTINCT chain FROM restaurants WHERE active=1" " AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain")], "sources": [dict(r) for r in con.execute( "SELECT source, COUNT(*) n FROM restaurants WHERE active=1" " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")], } con.close() return out @app.get("/api/sources") def sources(): registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] con = db.connect() counts = {r["source"]: r["n"] for r in con.execute( "SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source")} items = {r["source"]: r["n"] for r in con.execute( "SELECT r.source, SUM(m.item_count) n FROM menus m" " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source")} last = {r["source"]: r["ts"] for r in con.execute( "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} con.close() for s in registry: s["active_restaurants"] = counts.get(s["id"], 0) s["menu_items"] = items.get(s["id"], 0) s["last_sync"] = last.get(s["id"]) # ne pas exposer les clés d'intégration dans l'API publique if "integrations" in s: s["integrations"] = [{"label": i.get("label"), "status": i.get("status", "actif")} for i in s["integrations"]] return {"sources": registry} # --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) -------------------- _SITE = { "wordmark": "Resto·Ka", "accent": "#f08c00", # safran — on-accent encre #141814 "domain": "www.resto-ka.com", "tagline": "Chaque resto, chaque plat, chaque prix.", } @app.get("/api/stats/dashboard") def stats_dashboard( period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = Query(None), ): """Tableau de bord analytique (contrat commun Groupe KA, cache 5 min).""" from . import stats as kastats if period not in kastats.PERIOD_LABELS and not (from_ and to): raise HTTPException(400, "période inconnue") return kastats.dashboard(period, from_, to) @app.get("/api/stats/report") def stats_report( period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = Query(None), mode: str = "complet", ): """Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) : complet | synthese | tendances | repartitions | donnees. Un mode inconnu retombe sur complet (rétrocompatible v1).""" from . import kapdf from . import stats as kastats if mode not in kapdf.REPORT_MODES: mode = "complet" if period not in kastats.PERIOD_LABELS and not (from_ and to): raise HTTPException(400, "période inconnue") dash = kastats.dashboard(period, from_, to) pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build() fname = kapdf.filename("resto-ka", period, mode) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/stats/catalog") def stats_catalog( period: str = "30j", from_: str | None = Query(None, alias="from"), to: str | None = Query(None), ): """v3 — blocs composables pour le constructeur de rapports personnalisés.""" from . import kapdf from . import stats as kastats if period not in kastats.PERIOD_LABELS and not (from_ and to): period = "30j" dash = kastats.dashboard(period, from_, to) return {"updated": dash.get("updated"), "period": dash.get("period"), "blocks": kapdf.catalog(dash)} @app.post("/api/stats/report/custom") def stats_report_custom(spec: dict = Body(...)): """v3 — rapport PDF personnalisé : {"title", "period", "from", "to", "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis).""" from . import kapdf from . import stats as kastats period = str(spec.get("period") or "30j") from_ = str(spec.get("from") or "") or None to = str(spec.get("to") or "") or None if period not in kastats.PERIOD_LABELS and not (from_ and to): period = "30j" dash = kastats.dashboard(period, from_, to) known = {b["key"] for b in kapdf.catalog(dash)} blocks = [b for b in (spec.get("blocks") or []) if isinstance(b, dict) and b.get("key") in known][:40] if not blocks: raise HTTPException(400, "Aucun bloc valide dans la composition") pdf = kapdf.GroupeKAReport( site=_SITE, dashboard=dash, mode=kapdf.CUSTOM_MODE, spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks}, ).build() fname = kapdf.filename("resto-ka", period, kapdf.CUSTOM_MODE) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{fname}"'}) @app.get("/api/stats") def stats(): con = db.connect() head = dict(con.execute( """SELECT COUNT(*) restaurants, SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)) with_menu, COUNT(DISTINCT chain) chains, COUNT(DISTINCT region) regions, COUNT(DISTINCT source) sources FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone()) m = dict(con.execute( """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items FROM menus m JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 AND r.dup_of IS NULL""").fetchone()) by_region = [dict(r) for r in con.execute( "SELECT region, COUNT(*) n FROM restaurants WHERE active=1" " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")] by_context = [dict(r) for r in con.execute( "SELECT m.price_context, COUNT(*) n FROM menus m" " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1" " GROUP BY m.price_context")] log = [dict(r) for r in con.execute( "SELECT source, ts, found, added, updated, removed, ok, message" " FROM sync_log ORDER BY ts DESC LIMIT 20")] insp = dict(con.execute( """SELECT COUNT(*) inspections, SUM(uid IS NOT NULL) inspections_matched, COUNT(DISTINCT uid) restaurants_with_inspections FROM inspections""").fetchone()) enr = dict(con.execute( """SELECT SUM(details LIKE '%"yelp":%') with_yelp_rating, SUM(details LIKE '%"ubereats":%') with_ubereats, SUM(details LIKE '%"permis_alcool"%') with_alcohol_permit FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone()) con.close() try: # nb de sources au registre (data/sources.json), actives ou en attente registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] registered = len(registry) except (ValueError, OSError, KeyError): registered = None return {**head, **m, **insp, **enr, "sources_registry": registered, "by_region": by_region, "by_context": by_context, "recent_syncs": log} @app.post("/api/sync") def trigger_sync(background: BackgroundTasks, source: str | None = None): """Déclenche une synchronisation (équivalent d'un webhook entrant).""" def _job(): with _sync_lock: ingest.run([source] if source else None) ingest.enrich() background.add_task(_job) return {"status": "démarré", "source": source or "toutes"} # --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ----- @app.get("/api/favorites") def favorites(request: Request): """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local). Retourne aussi les fiches locales correspondantes (ordre du hub) pour l'affichage de la page /favoris.""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") items = hubfav.hub_list(user.get("ka_id") or "") if items is None: raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") ids = [i["item_id"] for i in items if i.get("item_id")] restos: list[dict] = [] if ids: con = db.connect() marks = ",".join("?" * len(ids)) rows = {r["uid"]: _row_to_dict(r) for r in con.execute( f"SELECT * FROM restaurants WHERE uid IN ({marks})", ids).fetchall()} menus = _menu_summaries(con, list(rows)) con.close() for uid in ids: r = rows.get(uid) if r is not None: r["menu_summary"] = menus.get(uid) restos.append(r) return {"ids": ids, "items": items, "restaurants": restos} @app.post("/api/favorites/toggle") def toggle_favorite(request: Request, body: dict = Body(...)): """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub Groupe KA de façon synchrone : le hub est la seule source de vérité.""" user = auth.current_user(request) if not user: raise HTTPException(401, "Connexion KA ID requise") ka_id = user.get("ka_id") or "" if not hubfav.linked(ka_id): raise HTTPException(403, "Compte non relié au hub Groupe KA") on = bool(body.get("on")) item = hubfav.clean_item(body.get("item") or {}) if not item.get("item_id"): raise HTTPException(422, "item.item_id requis") if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") # signal fort du moteur de préférences KA ID (features lues de la BD) con = db.connect() row = con.execute("SELECT * FROM restaurants WHERE uid=?", (item["item_id"],)).fetchone() con.close() kaid.track(user, "favorite" if on else "unfavorite", entity_type="restaurant", entity_id=item["item_id"], features=_kaid_features(_row_to_dict(row)) if row else None) return {"ok": True, "on": on} # --- Frontend React (build Vite) -------------------------------------------- if FRONTEND_DIST.exists(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") @app.middleware("http") async def _cache_headers(request, call_next): """Bundles hachés immuables ; index.html toujours revalidé.""" resp = await call_next(request) path = request.url.path if path.startswith("/assets/"): resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif "text/html" in (resp.headers.get("content-type") or ""): resp.headers["Cache-Control"] = "no-cache" return resp # Documentation utilisateur (frontend/public/doc → dist/doc) — routes # explicites AVANT le catch-all SPA pour servir /doc/ comme un index. @app.get("/doc") def doc_redirect(): return RedirectResponse("/doc/", status_code=308) @app.get("/doc/") def doc_index(): return FileResponse(FRONTEND_DIST / "doc" / "index.html") _CLIENT_PREFIXES = ("resto/", "sources", "stats", "favoris", "contact") @app.get("/{full_path:path}") def spa(full_path: str): target = FRONTEND_DIST / full_path if full_path and target.is_file(): return FileResponse(target) known = (full_path == "" or full_path in _CLIENT_PREFIXES or any(full_path.startswith(p) for p in _CLIENT_PREFIXES)) return FileResponse(FRONTEND_DIST / "index.html", status_code=200 if known else 404, headers={"Cache-Control": "no-cache"})