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, 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)444546def _row_to_dict(row) -> dict:47 d = dict(row)48 for col in ("cuisines", "services", "dietary_options", "languages", "images"):49 d[col] = json.loads(d.get(col) or "[]")50 d["hours"] = json.loads(d.get("hours") or "{}")51 try: # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details52 d["details"] = json.loads(d.get("details") or "{}")53 except (ValueError, TypeError):54 d["details"] = {}55 if d.get("dup_sources"):56 try:57 d["dup_sources"] = json.loads(d["dup_sources"])58 except (ValueError, TypeError):59 d["dup_sources"] = []60 return d616263def _menu_summaries(con, uids: list[str]) -> dict[str, dict]:64 """Résumé du MEILLEUR menu par resto (contexte préféré) pour les cartes."""65 if not uids:66 return {}67 qmarks = ",".join("?" * len(uids))68 best: dict[str, dict] = {}69 for r in con.execute(70 f"SELECT uid, price_context, price_source, captured_at, item_count,"71 f" sections FROM menus WHERE uid IN ({qmarks})", uids):72 cur = best.get(r["uid"])73 if cur and _CTX_ORDER.get(cur["price_context"], 9) <= \74 _CTX_ORDER.get(r["price_context"], 9):75 continue76 sections = json.loads(r["sections"] or "[]")77 prices = [it.get("price")78 for sec in sections79 for it in sec.get("items") or []80 if isinstance(it.get("price"), (int, float)) and it["price"] > 0]81 image = next((it["image"] for sec in sections82 for it in sec.get("items") or [] if it.get("image")),83 next((sec["image"] for sec in sections84 if sec.get("image")), None))85 best[r["uid"]] = {86 "image": image,87 "price_context": r["price_context"],88 "price_source": r["price_source"],89 "captured_at": r["captured_at"],90 "item_count": r["item_count"],91 "price_min": min(prices) if prices else None,92 "price_median": round(statistics.median(prices), 2) if prices else None,93 }94 return best959697@app.get("/healthz")98def healthz():99 return {"status": "ok"}100101102@app.get("/api/restaurants")103def list_restaurants(104 region: str | None = None,105 city: str | None = None,106 cuisine: str | None = None,107 establishment_type: str | None = None,108 diet: str | None = None, # vegan, sans-gluten…109 service: str | None = None, # takeout, delivery, salle110 price_range: str | None = None, # $ à $$$$111 chain: str | None = None,112 source: str | None = None,113 q: str | None = None,114 has_menu: int | None = None, # 1 = seulement les restos avec menu115 active: int = 1,116 sort: str = "menu", # menu | name | price | recent117 limit: int = Query(60, le=500),118 offset: int = 0,119):120 con = db.connect()121 sql = "SELECT * FROM restaurants WHERE dup_of IS NULL" # doublons masqués122 args: list = []123 if active in (0, 1):124 sql += " AND active=?"; args.append(active)125 if region:126 sql += " AND region=?"; args.append(region)127 if city:128 sql += " AND city=?"; args.append(city)129 if cuisine:130 sql += " AND cuisines LIKE ?"; args.append(f'%"{cuisine}"%')131 if establishment_type:132 sql += " AND establishment_type=?"; args.append(establishment_type)133 if diet:134 sql += " AND dietary_options LIKE ?"; args.append(f'%"{diet}"%')135 if service:136 sql += " AND services LIKE ?"; args.append(f'%"{service}"%')137 if price_range:138 sql += " AND price_range=?"; args.append(price_range)139 if chain:140 sql += " AND chain=?"; args.append(chain)141 if source:142 sql += " AND source=?"; args.append(source)143 if q:144 sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)"145 args += [f"%{q}%"] * 4146 if has_menu == 1:147 sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"148 elif has_menu == 0:149 sql += " AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"150 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]151 if sort == "recent":152 sql += " ORDER BY updated_at DESC"153 elif sort == "price":154 sql += " ORDER BY price_range='' , price_range ASC, name ASC"155 elif sort == "name":156 sql += " ORDER BY name COLLATE NOCASE ASC"157 else: # défaut « menu » : les restos avec menu complet d'abord158 sql += (" ORDER BY NOT EXISTS (SELECT 1 FROM menus m"159 " WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC")160 sql += " LIMIT ? OFFSET ?"161 args += [limit, offset]162 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]163 menus = _menu_summaries(con, [r["uid"] for r in rows])164 for r in rows:165 r["menu_summary"] = menus.get(r["uid"])166 con.close()167 return {"total": total, "count": len(rows), "restaurants": rows}168169170@app.get("/api/dishes")171def search_dishes(172 q: str = Query(..., min_length=2),173 region: str | None = None,174 city: str | None = None,175 cuisine: str | None = None,176 price_max: float | None = None,177 limit: int = Query(30, le=100),178 offset: int = 0,179):180 """Recherche PAR PLAT dans tous les menus (nom + description).181182 Les succursales d'une même chaîne partagent le même menu : les résultats183 sont regroupés par (marque, plat, prix, contexte) avec le nombre184 d'emplacements, pour ne pas noyer la liste sous 76 poutines identiques.185 """186 con = db.connect()187 needle = q.strip().lower()188 sql = ("SELECT m.uid, m.price_context, m.price_source, m.captured_at,"189 " m.sections, r.name rname, r.chain, r.city, r.region, r.cuisines"190 " FROM menus m JOIN restaurants r ON r.uid = m.uid"191 " WHERE r.active=1 AND r.dup_of IS NULL AND m.sections LIKE ?")192 args: list = [f"%{needle}%"]193 if region:194 sql += " AND r.region=?"; args.append(region)195 if city:196 sql += " AND r.city=?"; args.append(city)197 if cuisine:198 sql += " AND r.cuisines LIKE ?"; args.append(f'%"{cuisine}"%')199200 groups: dict[tuple, dict] = {}201 for row in con.execute(sql, args):202 try:203 sections = json.loads(row["sections"] or "[]")204 except ValueError:205 continue206 brand = row["chain"] or row["rname"]207 for sec in sections:208 for it in sec.get("items") or []:209 hay = f"{it.get('name', '')} {it.get('description', '')}".lower()210 if needle not in hay:211 continue212 price = it.get("price")213 if price_max is not None and (price is None or price > price_max):214 continue215 key = (brand, it.get("name"), price, row["price_context"])216 g = groups.get(key)217 if g is None:218 groups[key] = {219 "item": it.get("name"),220 "description": it.get("description") or "",221 "price": price,222 "currency": it.get("currency", "CAD"),223 "image": it.get("image"),224 "tags": it.get("tags") or [],225 "section": sec.get("name"),226 "price_context": row["price_context"],227 "price_source": row["price_source"],228 "captured_at": row["captured_at"],229 "brand": brand,230 "restaurant": row["rname"],231 "uid": row["uid"],232 "city": row["city"],233 "region": row["region"],234 "locations": 1,235 }236 else:237 g["locations"] += 1238 con.close()239 results = sorted(groups.values(),240 key=lambda g: (g["price"] is None, g["price"] or 0))241 total = len(results)242 return {"total": total, "count": min(limit, max(0, total - offset)),243 "dishes": results[offset:offset + limit]}244245246@app.get("/api/restaurants/{uid:path}/prices")247def price_history(uid: str):248 """Historique des prix par item (série temporelle, détection des hausses)."""249 con = db.connect()250 rows = [dict(r) for r in con.execute(251 "SELECT price_context, item_key, ts, price FROM item_price_log"252 " WHERE uid=? ORDER BY ts DESC LIMIT 500", (uid,))]253 con.close()254 return {"uid": uid, "prices": rows}255256257@app.get("/api/restaurants/{uid:path}/inspections")258def restaurant_inspections(uid: str):259 """Condamnations MAPAQ croisées avec ce restaurant (inspections260 alimentaires — Données Québec, licence CC-BY 4.0). Croisement261 conservateur : voir restoka/inspections.py."""262 con = db.connect()263 if con.execute("SELECT 1 FROM restaurants WHERE uid=?",264 (uid,)).fetchone() is None:265 con.close()266 raise HTTPException(404, "Restaurant introuvable")267 rows = [dict(r) for r in con.execute(268 "SELECT etablissement, exploitant, description, adresse,"269 " date_infraction, date_jugement, montant_amende, motif, matched_by"270 " FROM inspections WHERE uid=? ORDER BY date_jugement DESC", (uid,))]271 con.close()272 return {"uid": uid, "total": len(rows),273 "total_amendes": round(sum(r["montant_amende"] or 0 for r in rows), 2),274 "source": "MAPAQ — Condamnations des établissements alimentaires "275 "(Données Québec, CC-BY 4.0)",276 "inspections": rows}277278279@app.get("/api/restaurants/{uid:path}")280def get_restaurant(uid: str):281 con = db.connect()282 row = con.execute("SELECT * FROM restaurants WHERE uid=?", (uid,)).fetchone()283 if row is None:284 con.close()285 raise HTTPException(404, "Restaurant introuvable")286 d = _row_to_dict(row)287 # tous les contextes de prix disponibles, meilleur en premier — un menu288 # delivery n'est JAMAIS présenté comme un prix de salle (§6.2, §12.2)289 menus = []290 for m in con.execute(291 "SELECT price_context, price_source, currency, captured_at,"292 " item_count, sections FROM menus WHERE uid=?", (uid,)):293 menus.append({294 "price_context": m["price_context"],295 "price_source": m["price_source"],296 "currency": m["currency"],297 "captured_at": m["captured_at"],298 "item_count": m["item_count"],299 "sections": json.loads(m["sections"] or "[]"),300 })301 menus.sort(key=lambda m: _CTX_ORDER.get(m["price_context"], 9))302 d["menus"] = menus303 # items dont le prix a changé récemment (crédibilité, §12.2)304 d["recent_price_changes"] = [dict(r) for r in con.execute(305 "SELECT price_context, item_key, ts, price FROM item_price_log"306 " WHERE uid=? AND ts > (SELECT MIN(first_seen) FROM restaurants WHERE uid=?)"307 " GROUP BY item_key HAVING COUNT(*) > 1"308 " ORDER BY ts DESC LIMIT 20", (uid, uid))]309 con.close()310 return d311312313@app.get("/api/facets")314def facets(region: str | None = None):315 """Valeurs distinctes pour construire les filtres du frontend."""316 con = db.connect()317 city_sql = ("SELECT DISTINCT city FROM restaurants"318 " WHERE active=1 AND dup_of IS NULL AND city<>''")319 city_args: list = []320 if region:321 city_sql += " AND region=?"322 city_args.append(region)323 cuisines: dict[str, int] = {}324 diets: dict[str, int] = {}325 for r in con.execute("SELECT cuisines, dietary_options FROM restaurants"326 " WHERE active=1 AND dup_of IS NULL"):327 for c in json.loads(r["cuisines"] or "[]"):328 cuisines[c] = cuisines.get(c, 0) + 1329 for dd in json.loads(r["dietary_options"] or "[]"):330 diets[dd] = diets.get(dd, 0) + 1331 out = {332 "regions": [dict(r) for r in con.execute(333 "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"334 " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")],335 "all_regions": REGIONS,336 "cities": [r["city"] for r in con.execute(337 city_sql + " ORDER BY city", city_args)],338 "cuisines": [{"cuisine": c, "n": n} for c, n in339 sorted(cuisines.items(), key=lambda kv: -kv[1])],340 "diets": [{"diet": d, "n": n} for d, n in341 sorted(diets.items(), key=lambda kv: -kv[1])],342 "establishment_types": [dict(r) for r in con.execute(343 "SELECT establishment_type t, COUNT(*) n FROM restaurants"344 " WHERE active=1 AND dup_of IS NULL AND establishment_type<>''"345 " GROUP BY establishment_type ORDER BY n DESC")],346 "chains": [r["chain"] for r in con.execute(347 "SELECT DISTINCT chain FROM restaurants WHERE active=1"348 " AND dup_of IS NULL AND chain IS NOT NULL ORDER BY chain")],349 "sources": [dict(r) for r in con.execute(350 "SELECT source, COUNT(*) n FROM restaurants WHERE active=1"351 " AND dup_of IS NULL GROUP BY source ORDER BY n DESC")],352 }353 con.close()354 return out355356357@app.get("/api/sources")358def sources():359 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]360 con = db.connect()361 counts = {r["source"]: r["n"] for r in con.execute(362 "SELECT source, COUNT(*) n FROM restaurants WHERE active=1 GROUP BY source")}363 items = {r["source"]: r["n"] for r in con.execute(364 "SELECT r.source, SUM(m.item_count) n FROM menus m"365 " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1 GROUP BY r.source")}366 last = {r["source"]: r["ts"] for r in con.execute(367 "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}368 con.close()369 for s in registry:370 s["active_restaurants"] = counts.get(s["id"], 0)371 s["menu_items"] = items.get(s["id"], 0)372 s["last_sync"] = last.get(s["id"])373 # ne pas exposer les clés d'intégration dans l'API publique374 if "integrations" in s:375 s["integrations"] = [{"label": i.get("label"),376 "status": i.get("status", "actif")}377 for i in s["integrations"]]378 return {"sources": registry}379380381# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------382_SITE = {383 "wordmark": "Resto·Ka",384 "accent": "#f08c00", # safran — on-accent encre #141814385 "domain": "www.resto-ka.com",386 "tagline": "Chaque resto, chaque plat, chaque prix.",387}388389390@app.get("/api/stats/dashboard")391def stats_dashboard(392 period: str = "30j",393 from_: str | None = Query(None, alias="from"),394 to: str | None = Query(None),395):396 """Tableau de bord analytique (contrat commun Groupe KA, cache 5 min)."""397 from . import stats as kastats398 if period not in kastats.PERIOD_LABELS and not (from_ and to):399 raise HTTPException(400, "période inconnue")400 return kastats.dashboard(period, from_, to)401402403@app.get("/api/stats/report")404def stats_report(405 period: str = "30j",406 from_: str | None = Query(None, alias="from"),407 to: str | None = Query(None),408 mode: str = "complet",409):410 """Rapport PDF estampillé Groupe-KA — 5 modes (SPEC v2 §3) :411 complet | synthese | tendances | repartitions | donnees.412 Un mode inconnu retombe sur complet (rétrocompatible v1)."""413 from . import kapdf414 from . import stats as kastats415 if mode not in kapdf.REPORT_MODES:416 mode = "complet"417 if period not in kastats.PERIOD_LABELS and not (from_ and to):418 raise HTTPException(400, "période inconnue")419 dash = kastats.dashboard(period, from_, to)420 pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()421 fname = kapdf.filename("resto-ka", period, mode)422 return Response(content=pdf, media_type="application/pdf",423 headers={"Content-Disposition":424 f'attachment; filename="{fname}"'})425426427@app.get("/api/stats")428def stats():429 con = db.connect()430 head = dict(con.execute(431 """SELECT COUNT(*) restaurants,432 SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))433 with_menu,434 COUNT(DISTINCT chain) chains,435 COUNT(DISTINCT region) regions,436 COUNT(DISTINCT source) sources437 FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())438 m = dict(con.execute(439 """SELECT COUNT(*) menus, COALESCE(SUM(item_count),0) items440 FROM menus m JOIN restaurants r ON r.uid=m.uid441 WHERE r.active=1 AND r.dup_of IS NULL""").fetchone())442 by_region = [dict(r) for r in con.execute(443 "SELECT region, COUNT(*) n FROM restaurants WHERE active=1"444 " AND dup_of IS NULL AND region<>'' GROUP BY region ORDER BY n DESC")]445 by_context = [dict(r) for r in con.execute(446 "SELECT m.price_context, COUNT(*) n FROM menus m"447 " JOIN restaurants r ON r.uid=m.uid WHERE r.active=1"448 " GROUP BY m.price_context")]449 log = [dict(r) for r in con.execute(450 "SELECT source, ts, found, added, updated, removed, ok, message"451 " FROM sync_log ORDER BY ts DESC LIMIT 20")]452 insp = dict(con.execute(453 """SELECT COUNT(*) inspections,454 SUM(uid IS NOT NULL) inspections_matched,455 COUNT(DISTINCT uid) restaurants_with_inspections456 FROM inspections""").fetchone())457 enr = dict(con.execute(458 """SELECT SUM(details LIKE '%"yelp":%') with_yelp_rating,459 SUM(details LIKE '%"ubereats":%') with_ubereats,460 SUM(details LIKE '%"permis_alcool"%') with_alcohol_permit461 FROM restaurants WHERE active=1 AND dup_of IS NULL""").fetchone())462 con.close()463 try: # nb de sources au registre (data/sources.json), actives ou en attente464 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]465 registered = len(registry)466 except (ValueError, OSError, KeyError):467 registered = None468 return {**head, **m, **insp, **enr, "sources_registry": registered,469 "by_region": by_region, "by_context": by_context,470 "recent_syncs": log}471472473@app.post("/api/sync")474def trigger_sync(background: BackgroundTasks, source: str | None = None):475 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""476 def _job():477 with _sync_lock:478 ingest.run([source] if source else None)479 ingest.enrich()480 background.add_task(_job)481 return {"status": "démarré", "source": source or "toutes"}482483484# --- Frontend React (build Vite) --------------------------------------------485if FRONTEND_DIST.exists():486 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"),487 name="assets")488489 @app.middleware("http")490 async def _cache_headers(request, call_next):491 """Bundles hachés immuables ; index.html toujours revalidé."""492 resp = await call_next(request)493 path = request.url.path494 if path.startswith("/assets/"):495 resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"496 elif "text/html" in (resp.headers.get("content-type") or ""):497 resp.headers["Cache-Control"] = "no-cache"498 return resp499500 _CLIENT_PREFIXES = ("resto/", "sources", "stats", "favoris", "contact")501502 @app.get("/{full_path:path}")503 def spa(full_path: str):504 target = FRONTEND_DIST / full_path505 if full_path and target.is_file():506 return FileResponse(target)507 known = (full_path == "" or full_path in _CLIENT_PREFIXES508 or any(full_path.startswith(p) for p in _CLIENT_PREFIXES))509 return FileResponse(FRONTEND_DIST / "index.html",510 status_code=200 if known else 404,511 headers={"Cache-Control": "no-cache"})512