""" Application web FastAPI pour le dashboard TicketTracker. Routes HTML (lecture seule) : GET / → index.html (stats + graphique + derniers tickets) GET /compare → compare.html (comparaison Picnic vs Leclerc) GET /product/{name:path} → product.html (historique d'un produit) GET /receipt/{id} → receipt.html (détail d'un ticket) Lancement : python -m tickettracker.web.app ou TICKETTRACKER_DB_PATH=/autre/chemin.db python -m tickettracker.web.app """ import json from pathlib import Path from urllib.parse import quote from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import tickettracker.config as config from tickettracker.db.schema import get_connection, init_db from tickettracker.web.api import router as api_router from tickettracker.web.queries import ( get_all_receipts, get_compare_prices, get_dashboard_stats, get_monthly_spending, get_pending_matches, get_product_history, get_product_list, get_receipt_detail, ) # --------------------------------------------------------------------------- # Initialisation de l'application # --------------------------------------------------------------------------- app = FastAPI(title="TicketTracker Dashboard", docs_url="/api/docs", redoc_url=None) # Répertoires statiques et templates (relatifs à ce fichier) _WEB_DIR = Path(__file__).parent _STATIC_DIR = _WEB_DIR / "static" _TEMPLATES_DIR = _WEB_DIR / "templates" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) # Filtre Jinja2 pour encoder les noms de produits dans les URLs templates.env.filters["urlquote"] = lambda s: quote(str(s), safe="") # Router API JSON app.include_router(api_router) # --------------------------------------------------------------------------- # Helper : transforme la liste plate monthly en structure Chart.js # --------------------------------------------------------------------------- def _build_monthly_chart_data(monthly: list[dict]) -> dict: """Convertit [{month, store, total}] en structure datasets Chart.js stacked bar. Retourne un dict sérialisable en JSON : { "labels": ["2026-01", ...], "datasets": [ {"label": "picnic", "data": [...], "backgroundColor": "#4a9eff"}, {"label": "leclerc", "data": [...], "backgroundColor": "#ff6b35"}, ] } Les totaux manquants (enseigne absente pour un mois) sont mis à 0. """ # Extraire tous les mois et enseignes distincts (ordonnés) labels = sorted({row["month"] for row in monthly}) stores = sorted({row["store"] for row in monthly}) # Construire un index {(month, store): total} pour lookup rapide index = {(row["month"], row["store"]): row["total"] for row in monthly} # Couleurs associées aux enseignes colors = {"picnic": "#4a9eff", "leclerc": "#ff6b35"} datasets = [ { "label": store, "data": [index.get((month, store), 0) for month in labels], "backgroundColor": colors.get(store, "#888888"), } for store in stores ] return {"labels": labels, "datasets": datasets} # --------------------------------------------------------------------------- # Routes HTML # --------------------------------------------------------------------------- @app.get("/", response_class=HTMLResponse) async def page_index(request: Request): """Page d'accueil : statistiques globales + graphique + liste des tickets.""" conn = get_connection(config.DB_PATH) try: stats = get_dashboard_stats(conn) monthly = get_monthly_spending(conn) receipts = get_all_receipts(conn) finally: conn.close() chart_data = _build_monthly_chart_data(monthly) empty = stats["total_receipts"] == 0 return templates.TemplateResponse( request, "index.html", { "stats": stats, "chart_data": chart_data, "receipts": receipts, "empty": empty, }, ) @app.get("/compare", response_class=HTMLResponse) async def page_compare(request: Request): """Page de comparaison des prix Picnic vs Leclerc.""" conn = get_connection(config.DB_PATH) try: products = get_compare_prices(conn) finally: conn.close() return templates.TemplateResponse( request, "compare.html", { "products": products, "empty": len(products) == 0, }, ) @app.get("/product/{name:path}", response_class=HTMLResponse) async def page_product(request: Request, name: str): """Page historique d'un produit normalisé.""" conn = get_connection(config.DB_PATH) try: data = get_product_history(conn, name) all_products = get_product_list(conn) finally: conn.close() return templates.TemplateResponse( request, "product.html", { "data": data, "name": name, "all_products": all_products, "empty": data is None, }, ) @app.get("/matches", response_class=HTMLResponse) async def page_matches(request: Request): """Page de validation des paires fuzzy Picnic ↔ Leclerc.""" conn = get_connection(config.DB_PATH) try: pending = get_pending_matches(conn) validated_count = conn.execute( "SELECT COUNT(*) FROM product_matches WHERE status='validated'" ).fetchone()[0] rejected_count = conn.execute( "SELECT COUNT(*) FROM product_matches WHERE status='rejected'" ).fetchone()[0] finally: conn.close() return templates.TemplateResponse( request, "matches.html", { "pending": pending, "pending_json": json.dumps(pending), "validated_count": validated_count, "rejected_count": rejected_count, }, ) @app.get("/receipt/{receipt_id}", response_class=HTMLResponse) async def page_receipt(request: Request, receipt_id: int): """Page détail d'un ticket.""" conn = get_connection(config.DB_PATH) try: data = get_receipt_detail(conn, receipt_id) finally: conn.close() if data is None: return templates.TemplateResponse( request, "receipt.html", {"data": None, "receipt_id": receipt_id}, status_code=404, ) return templates.TemplateResponse( request, "receipt.html", {"data": data}, ) # --------------------------------------------------------------------------- # Point d'entrée : python -m tickettracker.web.app # --------------------------------------------------------------------------- if __name__ == "__main__": import uvicorn # S'assurer que la DB existe (idempotent) init_db(config.DB_PATH) print(f"Base de données : {config.DB_PATH}") print("Dashboard disponible sur http://localhost:8000") uvicorn.run("tickettracker.web.app:app", host="0.0.0.0", port=8000, reload=True)