feat: dashboard web FastAPI Sprint 4
Ajout d'un dashboard lecture seule par-dessus la DB SQLite existante. Fichiers créés : - tickettracker/web/queries.py : 7 fonctions SQL (stats, compare, historique...) - tickettracker/web/api.py : router /api/* JSON (FastAPI) - tickettracker/web/app.py : routes HTML + Jinja2 + point d'entrée uvicorn - tickettracker/web/templates/ : base.html, index.html, compare.html, product.html, receipt.html - tickettracker/web/static/style.css : personnalisations Pico CSS - tests/test_web.py : 19 tests (96 passent, 1 xfail OCR) Fichiers modifiés : - requirements.txt : +fastapi, uvicorn[standard], jinja2, python-multipart, httpx - config.py : +DB_PATH (lu depuis TICKETTRACKER_DB_PATH) Lancement : python -m tickettracker.web.app Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
206
tickettracker/web/app.py
Normal file
206
tickettracker/web/app.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
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_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("/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)
|
||||
Reference in New Issue
Block a user