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>
2026-02-24 20:04:55 +01:00
|
|
|
"""
|
|
|
|
|
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,
|
2026-02-25 18:02:48 +01:00
|
|
|
get_pending_matches,
|
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>
2026-02-24 20:04:55 +01:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 18:02:48 +01:00
|
|
|
@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,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
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>
2026-02-24 20:04:55 +01:00
|
|
|
@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)
|