Files
TicketTracker/tickettracker/web/app.py

234 lines
7.1 KiB
Python
Raw Normal View History

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