SPB Git

spb/resto-ka

Public

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

21.8 KB · 523 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, FastAPI, HTTPException, Query17from fastapi.middleware.cors import CORSMiddleware18from fastapi.middleware.gzip import GZipMiddleware19from fastapi.responses import FileResponse, Response20from fastapi.staticfiles import StaticFiles2122from . import db, 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")439def stats():440    con = db.connect()441    head = dict(con.execute(442        """SELECT COUNT(*) restaurants,443                  SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))444                      with_menu,445                  COUNT(DISTINCT chain) chains,446                  COUNT(DISTINCT region) regions,447                  COUNT(DISTINCT source) sources448           FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())449    m = dict(con.execute(450        """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items451           FROM menus m JOIN restaurants r ON r.uid=m.uid452           WHERE r.active=1 AND r.dup_of IS NULL""").fetchone())453    by_region = [dict(r) for r in con.execute(454        "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"455        " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")]456    by_context = [dict(r) for r in con.execute(457        "SELECT m.price_context, COUNT(*) n FROM menus m"458        " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1"459        " GROUP BY m.price_context")]460    log = [dict(r) for r in con.execute(461        "SELECT source, ts, found, added, updated, removed, ok, message"462        " FROM sync_log ORDER BY ts DESC LIMIT 20")]463    insp = dict(con.execute(464        """SELECT COUNT(*) inspections,465                  SUM(uid IS NOT NULL) inspections_matched,466                  COUNT(DISTINCT uid) restaurants_with_inspections467           FROM inspections""").fetchone())468    enr = dict(con.execute(469        """SELECT SUM(details LIKE '%"yelp":%') with_yelp_rating,470                  SUM(details LIKE '%"ubereats":%') with_ubereats,471                  SUM(details LIKE '%"permis_alcool"%') with_alcohol_permit472           FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())473    con.close()474    try:  # nb de sources au registre (data/sources.json), actives ou en attente475        registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]476        registered = len(registry)477    except (ValueError, OSError, KeyError):478        registered = None479    return {**head, **m, **insp, **enr, "sources_registry": registered,480            "by_region": by_region, "by_context": by_context,481            "recent_syncs": log}482483484@app.post("/api/sync")485def trigger_sync(background: BackgroundTasks, source: str | None = None):486    """Déclenche une synchronisation (équivalent d'un webhook entrant)."""487    def _job():488        with _sync_lock:489            ingest.run([source] if source else None)490            ingest.enrich()491    background.add_task(_job)492    return {"status": "démarré", "source": source or "toutes"}493494495# --- Frontend React (build Vite) --------------------------------------------496if FRONTEND_DIST.exists():497    app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"),498              name="assets")499500    @app.middleware("http")501    async def _cache_headers(request, call_next):502        """Bundles hachés immuables ; index.html toujours revalidé."""503        resp = await call_next(request)504        path = request.url.path505        if path.startswith("/assets/"):506            resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"507        elif "text/html" in (resp.headers.get("content-type") or ""):508            resp.headers["Cache-Control"] = "no-cache"509        return resp510511    _CLIENT_PREFIXES = ("resto/", "sources", "stats", "favoris", "contact")512513    @app.get("/{full_path:path}")514    def spa(full_path: str):515        target = FRONTEND_DIST / full_path516        if full_path and target.is_file():517            return FileResponse(target)518        known = (full_path == "" or full_path in _CLIENT_PREFIXES519                 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))520        return FileResponse(FRONTEND_DIST / "index.html",521                            status_code=200 if known else 404,522                            headers={"Cache-Control": "no-cache"})523
Files · 9e498cba38d2d92bc7489247e8c9028bc1aea810
Menu

Theme