SPB Git

spb/resto-ka

Public

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

26.1 KB · 625 lines python
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
Files · dd95e5acee88138021ec360e454bdbda9386b0d3
Menu

Theme