# ============================================================================== # 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, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles from . import db, ingest 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) 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( 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, 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 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() return {"total": total, "count": len(rows), "restaurants": rows} @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): 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() 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") 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"} # --- 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 _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"})