Files
TicketTracker/tickettracker/web/app.py
laurent b2934ed190 feat: refonte UX page /matches — révision carte par carte
- Remplace la table trop large par une carte de révision centrée
- Une paire à la fois : noms wrappés, score, prix moyens
- Valider/Rejeter via fetch() sans rechargement de page
- Passage automatique à la paire suivante après chaque action
- Compteurs mis à jour en temps réel (en attente/validées/rejetées)
- Message de fin avec lien vers /compare quand tout est traité
- Ajout tests pytest pour la page /matches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:45:51 +01:00

235 lines
7.1 KiB
Python

"""
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)