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:
@@ -10,6 +10,13 @@ Pillow>=10.0 # manipulation d'images (extraction JPEG du PDF)
|
|||||||
# LLM (appels API OpenAI-compatible)
|
# LLM (appels API OpenAI-compatible)
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
|
|
||||||
|
# Web (dashboard FastAPI)
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.30
|
||||||
|
jinja2>=3.1
|
||||||
|
python-multipart>=0.0.12
|
||||||
|
httpx>=0.27 # requis par TestClient FastAPI
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
|
|
||||||
|
|||||||
270
tests/test_web.py
Normal file
270
tests/test_web.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
Tests du dashboard web FastAPI (Sprint 4).
|
||||||
|
|
||||||
|
Stratégie :
|
||||||
|
- Deux familles de fixtures : DB vide et DB avec données
|
||||||
|
- On patche tickettracker.config.DB_PATH pour que l'appli pointe sur la DB de test
|
||||||
|
- TestClient de FastAPI/httpx pour simuler les requêtes HTTP sans lancer de serveur
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tickettracker.db import schema, repository
|
||||||
|
from tickettracker.models.receipt import Item, Receipt
|
||||||
|
from tickettracker.web.app import app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Données synthétiques réutilisées par les fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_picnic_receipt() -> Receipt:
|
||||||
|
"""Ticket Picnic avec deux articles dont un produit commun."""
|
||||||
|
return Receipt(
|
||||||
|
store="picnic",
|
||||||
|
date=date(2026, 1, 10),
|
||||||
|
total=15.50,
|
||||||
|
delivery_fee=1.99,
|
||||||
|
order_id="PICNIC-001",
|
||||||
|
items=[
|
||||||
|
Item(
|
||||||
|
name="Lait demi-écremé",
|
||||||
|
quantity=1,
|
||||||
|
unit="pièce",
|
||||||
|
unit_price=1.05,
|
||||||
|
total_price=1.05,
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
name="Jus d'orange",
|
||||||
|
quantity=2,
|
||||||
|
unit="pièce",
|
||||||
|
unit_price=2.10,
|
||||||
|
total_price=4.20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_leclerc_receipt() -> Receipt:
|
||||||
|
"""Ticket Leclerc avec deux articles dont un produit commun."""
|
||||||
|
return Receipt(
|
||||||
|
store="leclerc",
|
||||||
|
date=date(2026, 1, 15),
|
||||||
|
total=22.30,
|
||||||
|
items=[
|
||||||
|
Item(
|
||||||
|
name="LAIT DEMI ECREME",
|
||||||
|
quantity=1,
|
||||||
|
unit="pièce",
|
||||||
|
unit_price=0.95,
|
||||||
|
total_price=0.95,
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
name="FARINE BLE",
|
||||||
|
quantity=1,
|
||||||
|
unit="pièce",
|
||||||
|
unit_price=1.20,
|
||||||
|
total_price=1.20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_path(tmp_path: Path) -> Path:
|
||||||
|
"""Base SQLite vide dans un répertoire temporaire."""
|
||||||
|
path = tmp_path / "test_web.db"
|
||||||
|
schema.init_db(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_path_with_data(db_path: Path) -> Path:
|
||||||
|
"""Base avec 1 ticket Picnic + 1 ticket Leclerc, 1 produit normalisé en commun."""
|
||||||
|
conn = schema.get_connection(db_path)
|
||||||
|
try:
|
||||||
|
repository.insert_receipt(conn, _make_picnic_receipt())
|
||||||
|
repository.insert_receipt(conn, _make_leclerc_receipt())
|
||||||
|
|
||||||
|
# Normaliser manuellement le produit commun (simule le travail de la CLI normalize)
|
||||||
|
with conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE items SET name_normalized = 'Lait demi-écremé'"
|
||||||
|
" WHERE name_raw IN ('Lait demi-écremé', 'LAIT DEMI ECREME')"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(db_path: Path):
|
||||||
|
"""TestClient sur la DB vide."""
|
||||||
|
with patch("tickettracker.config.DB_PATH", db_path):
|
||||||
|
yield TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_data(db_path_with_data: Path):
|
||||||
|
"""TestClient sur la DB avec données."""
|
||||||
|
with patch("tickettracker.config.DB_PATH", db_path_with_data):
|
||||||
|
yield TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests HTML — DB vide
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_index_empty_200(client):
|
||||||
|
"""Page d'accueil accessible même si la base est vide."""
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_empty_shows_message(client):
|
||||||
|
"""Page d'accueil affiche le message 'Aucun ticket' quand la base est vide."""
|
||||||
|
resp = client.get("/")
|
||||||
|
assert "Aucun ticket" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_empty_200(client):
|
||||||
|
"""Page /compare accessible même si la base est vide."""
|
||||||
|
resp = client.get("/compare")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_unknown_200(client):
|
||||||
|
"""GET /product/<inconnu> retourne 200 (pas 500) — affiche un message 'introuvable'."""
|
||||||
|
resp = client.get("/product/ProduitInexistant")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "introuvable" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_not_found_404(client):
|
||||||
|
"""GET /receipt/999 retourne 404 quand le ticket n'existe pas."""
|
||||||
|
resp = client.get("/receipt/999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests HTML — DB avec données
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_index_with_data_200(client_with_data):
|
||||||
|
"""Page d'accueil est accessible avec des données."""
|
||||||
|
resp = client_with_data.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_mentions_store(client_with_data):
|
||||||
|
"""Page d'accueil mentionne l'enseigne picnic."""
|
||||||
|
resp = client_with_data.get("/")
|
||||||
|
assert "picnic" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_with_data_shows_product(client_with_data):
|
||||||
|
"""Page /compare affiche le produit commun normalisé."""
|
||||||
|
resp = client_with_data.get("/compare")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Lait demi-écremé" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_detail_200(client_with_data):
|
||||||
|
"""GET /receipt/1 retourne 200 quand le ticket existe."""
|
||||||
|
resp = client_with_data.get("/receipt/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_detail_contains_store(client_with_data):
|
||||||
|
"""Page /receipt/1 contient le nom de l'enseigne."""
|
||||||
|
resp = client_with_data.get("/receipt/1")
|
||||||
|
assert "picnic" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests API — DB vide
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_api_stats_empty(client):
|
||||||
|
"""GET /api/stats sur base vide retourne total_receipts = 0."""
|
||||||
|
resp = client.get("/api/stats")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_receipts"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_compare_empty(client):
|
||||||
|
"""GET /api/compare sur base vide retourne une liste vide."""
|
||||||
|
resp = client.get("/api/compare")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_receipts_empty(client):
|
||||||
|
"""GET /api/receipts sur base vide retourne une liste vide."""
|
||||||
|
resp = client.get("/api/receipts")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_receipt_not_found(client):
|
||||||
|
"""GET /api/receipt/999 retourne 404."""
|
||||||
|
resp = client.get("/api/receipt/999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests API — DB avec données
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_api_stats_with_data(client_with_data):
|
||||||
|
"""GET /api/stats avec 2 tickets retourne total_receipts = 2."""
|
||||||
|
resp = client_with_data.get("/api/stats")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_receipts"] == 2
|
||||||
|
assert data["receipts_by_store"]["picnic"] == 1
|
||||||
|
assert data["receipts_by_store"]["leclerc"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_compare_returns_common_product(client_with_data):
|
||||||
|
"""GET /api/compare retourne le produit normalisé commun aux deux enseignes."""
|
||||||
|
resp = client_with_data.get("/api/compare")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
products = resp.json()
|
||||||
|
assert len(products) >= 1
|
||||||
|
names = [p["name"] for p in products]
|
||||||
|
assert "Lait demi-écremé" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_receipt_detail_has_items(client_with_data):
|
||||||
|
"""GET /api/receipt/1 retourne un ticket avec des articles."""
|
||||||
|
resp = client_with_data.get("/api/receipt/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["store"] == "picnic"
|
||||||
|
assert len(data["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_product_history(client_with_data):
|
||||||
|
"""GET /api/product/<nom>/history retourne l'historique du produit commun."""
|
||||||
|
resp = client_with_data.get("/api/product/Lait demi-écremé/history")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Lait demi-écremé"
|
||||||
|
assert len(data["history"]) == 2 # 1 occurrence Picnic + 1 Leclerc
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_product_history_not_found(client_with_data):
|
||||||
|
"""GET /api/product/<inconnu>/history retourne 404."""
|
||||||
|
resp = client_with_data.get("/api/product/ProduitInexistant/history")
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -13,6 +13,16 @@ Variables d'environnement disponibles :
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base de données
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from tickettracker.db.schema import DEFAULT_DB_PATH as _DEFAULT_DB_PATH
|
||||||
|
|
||||||
|
# Chemin vers la base SQLite (surchargeable par variable d'environnement)
|
||||||
|
DB_PATH: Path = Path(os.environ.get("TICKETTRACKER_DB_PATH", str(_DEFAULT_DB_PATH)))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# LLM
|
# LLM
|
||||||
|
|||||||
0
tickettracker/web/__init__.py
Normal file
0
tickettracker/web/__init__.py
Normal file
87
tickettracker/web/api.py
Normal file
87
tickettracker/web/api.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
Router FastAPI pour les endpoints JSON /api/*.
|
||||||
|
|
||||||
|
Chaque endpoint ouvre sa propre connexion SQLite (via config.DB_PATH),
|
||||||
|
appelle la fonction de queries.py correspondante, puis ferme la connexion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
import tickettracker.config as config
|
||||||
|
from tickettracker.db.schema import get_connection
|
||||||
|
from tickettracker.web.queries import (
|
||||||
|
get_all_receipts,
|
||||||
|
get_compare_prices,
|
||||||
|
get_dashboard_stats,
|
||||||
|
get_product_history,
|
||||||
|
get_receipt_detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def api_stats():
|
||||||
|
"""Statistiques globales (nb tickets, total dépensé, etc.)."""
|
||||||
|
conn = get_connection(config.DB_PATH)
|
||||||
|
try:
|
||||||
|
return get_dashboard_stats(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compare")
|
||||||
|
def api_compare():
|
||||||
|
"""Comparaison de prix Picnic vs Leclerc pour les produits communs."""
|
||||||
|
conn = get_connection(config.DB_PATH)
|
||||||
|
try:
|
||||||
|
return get_compare_prices(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/product/{name:path}/history")
|
||||||
|
def api_product_history(name: str):
|
||||||
|
"""Historique des prix d'un produit normalisé.
|
||||||
|
|
||||||
|
Retourne 404 si le produit est inconnu.
|
||||||
|
Le paramètre {name:path} autorise les '/' dans le nom normalisé.
|
||||||
|
"""
|
||||||
|
conn = get_connection(config.DB_PATH)
|
||||||
|
try:
|
||||||
|
data = get_product_history(conn, name)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Produit introuvable")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/receipts")
|
||||||
|
def api_receipts():
|
||||||
|
"""Liste tous les tickets avec leur nombre d'articles."""
|
||||||
|
conn = get_connection(config.DB_PATH)
|
||||||
|
try:
|
||||||
|
return get_all_receipts(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/receipt/{receipt_id}")
|
||||||
|
def api_receipt_detail(receipt_id: int):
|
||||||
|
"""Détail d'un ticket et de ses articles.
|
||||||
|
|
||||||
|
Retourne 404 si l'id est inconnu.
|
||||||
|
"""
|
||||||
|
conn = get_connection(config.DB_PATH)
|
||||||
|
try:
|
||||||
|
data = get_receipt_detail(conn, receipt_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Ticket introuvable")
|
||||||
|
return data
|
||||||
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)
|
||||||
297
tickettracker/web/queries.py
Normal file
297
tickettracker/web/queries.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Requêtes SQL en lecture seule pour le dashboard web.
|
||||||
|
|
||||||
|
Toutes les fonctions reçoivent une connexion SQLite ouverte (pattern identique
|
||||||
|
à repository.py) et retournent des structures Python simples (dict, list).
|
||||||
|
L'appelant est responsable de l'ouverture et fermeture de la connexion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard_stats(conn: sqlite3.Connection) -> dict:
|
||||||
|
"""Statistiques globales pour la page d'accueil.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict avec les clés :
|
||||||
|
- total_receipts : int
|
||||||
|
- total_spent : float
|
||||||
|
- total_items : int
|
||||||
|
- distinct_products : int
|
||||||
|
- receipts_by_store : dict[str, int]
|
||||||
|
- spent_by_store : dict[str, float]
|
||||||
|
- date_range : dict {min, max} ou {min: None, max: None}
|
||||||
|
"""
|
||||||
|
# Statistiques par enseigne
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT store, COUNT(*) AS nb, SUM(total) AS spent FROM receipts GROUP BY store"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
receipts_by_store = {row["store"]: row["nb"] for row in rows}
|
||||||
|
spent_by_store = {row["store"]: round(row["spent"], 2) for row in rows}
|
||||||
|
total_receipts = sum(receipts_by_store.values())
|
||||||
|
total_spent = round(sum(row["spent"] for row in rows), 2) if rows else 0.0
|
||||||
|
|
||||||
|
# Statistiques articles
|
||||||
|
item_stats = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_items,
|
||||||
|
COUNT(DISTINCT name_normalized) AS distinct_products
|
||||||
|
FROM items
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
# Plage de dates
|
||||||
|
date_row = conn.execute(
|
||||||
|
"SELECT MIN(date) AS d_min, MAX(date) AS d_max FROM receipts"
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_receipts": total_receipts,
|
||||||
|
"total_spent": total_spent,
|
||||||
|
"total_items": item_stats["total_items"],
|
||||||
|
"distinct_products": item_stats["distinct_products"],
|
||||||
|
"receipts_by_store": receipts_by_store,
|
||||||
|
"spent_by_store": spent_by_store,
|
||||||
|
"date_range": {"min": date_row["d_min"], "max": date_row["d_max"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_monthly_spending(conn: sqlite3.Connection) -> list[dict]:
|
||||||
|
"""Dépenses mensuelles par enseigne, pour le graphique Chart.js.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de dicts {month: "2026-01", store: "picnic", total: 45.20},
|
||||||
|
triée par mois puis enseigne.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
substr(date, 1, 7) AS month,
|
||||||
|
store,
|
||||||
|
ROUND(SUM(total), 2) AS total
|
||||||
|
FROM receipts
|
||||||
|
GROUP BY month, store
|
||||||
|
ORDER BY month, store
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [{"month": r["month"], "store": r["store"], "total": r["total"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_compare_prices(conn: sqlite3.Connection) -> list[dict]:
|
||||||
|
"""Comparaison de prix entre Picnic et Leclerc pour les produits communs.
|
||||||
|
|
||||||
|
Utilise la vue price_history. Ne retourne que les produits présents
|
||||||
|
dans les deux enseignes. Trié par écart décroissant (le plus cher en premier).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de dicts {name, price_picnic, price_leclerc, diff, diff_pct}.
|
||||||
|
diff = price_leclerc - price_picnic (positif = Leclerc plus cher)
|
||||||
|
diff_pct = diff / MIN(price_picnic, price_leclerc) * 100
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
WITH avg_by_store AS (
|
||||||
|
SELECT
|
||||||
|
name_normalized,
|
||||||
|
store,
|
||||||
|
ROUND(AVG(unit_price), 2) AS avg_price
|
||||||
|
FROM price_history
|
||||||
|
WHERE name_normalized IS NOT NULL
|
||||||
|
GROUP BY name_normalized, store
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
a.name_normalized AS name,
|
||||||
|
a.avg_price AS price_picnic,
|
||||||
|
b.avg_price AS price_leclerc,
|
||||||
|
ROUND(b.avg_price - a.avg_price, 2) AS diff,
|
||||||
|
ROUND(
|
||||||
|
(b.avg_price - a.avg_price)
|
||||||
|
/ MIN(a.avg_price, b.avg_price) * 100
|
||||||
|
, 1) AS diff_pct
|
||||||
|
FROM avg_by_store a
|
||||||
|
JOIN avg_by_store b
|
||||||
|
ON a.name_normalized = b.name_normalized
|
||||||
|
AND a.store = 'picnic'
|
||||||
|
AND b.store = 'leclerc'
|
||||||
|
ORDER BY ABS(b.avg_price - a.avg_price) DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": r["name"],
|
||||||
|
"price_picnic": r["price_picnic"],
|
||||||
|
"price_leclerc": r["price_leclerc"],
|
||||||
|
"diff": r["diff"],
|
||||||
|
"diff_pct": r["diff_pct"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_product_history(conn: sqlite3.Connection, name: str) -> dict | None:
|
||||||
|
"""Historique des prix d'un produit normalisé.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Connexion SQLite ouverte.
|
||||||
|
name: Valeur de name_normalized à rechercher (sensible à la casse).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {name, min_price, max_price, avg_price, history: list[dict]}
|
||||||
|
ou None si le produit est inconnu.
|
||||||
|
Chaque entrée de history : {date, store, unit_price, quantity, unit}.
|
||||||
|
"""
|
||||||
|
# Statistiques globales
|
||||||
|
stats = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
name_normalized,
|
||||||
|
ROUND(MIN(unit_price), 2) AS min_price,
|
||||||
|
ROUND(MAX(unit_price), 2) AS max_price,
|
||||||
|
ROUND(AVG(unit_price), 2) AS avg_price
|
||||||
|
FROM price_history
|
||||||
|
WHERE name_normalized = ?
|
||||||
|
""",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if stats is None or stats["name_normalized"] is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Historique chronologique
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, store, unit_price, quantity, unit
|
||||||
|
FROM price_history
|
||||||
|
WHERE name_normalized = ?
|
||||||
|
ORDER BY date
|
||||||
|
""",
|
||||||
|
(name,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": stats["name_normalized"],
|
||||||
|
"min_price": stats["min_price"],
|
||||||
|
"max_price": stats["max_price"],
|
||||||
|
"avg_price": stats["avg_price"],
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": r["date"],
|
||||||
|
"store": r["store"],
|
||||||
|
"unit_price": r["unit_price"],
|
||||||
|
"quantity": r["quantity"],
|
||||||
|
"unit": r["unit"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_receipts(conn: sqlite3.Connection) -> list[dict]:
|
||||||
|
"""Liste tous les tickets avec le nombre d'articles associés.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de dicts {id, store, date, total, delivery_fee, order_id, nb_items},
|
||||||
|
triée par date décroissante (le plus récent en premier).
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.store,
|
||||||
|
r.date,
|
||||||
|
r.total,
|
||||||
|
r.delivery_fee,
|
||||||
|
r.order_id,
|
||||||
|
COUNT(i.id) AS nb_items
|
||||||
|
FROM receipts r
|
||||||
|
LEFT JOIN items i ON i.receipt_id = r.id
|
||||||
|
GROUP BY r.id
|
||||||
|
ORDER BY r.date DESC, r.id DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"store": r["store"],
|
||||||
|
"date": r["date"],
|
||||||
|
"total": r["total"],
|
||||||
|
"delivery_fee": r["delivery_fee"],
|
||||||
|
"order_id": r["order_id"],
|
||||||
|
"nb_items": r["nb_items"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_receipt_detail(conn: sqlite3.Connection, receipt_id: int) -> dict | None:
|
||||||
|
"""Détail complet d'un ticket et de ses articles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Connexion SQLite ouverte.
|
||||||
|
receipt_id: Id du ticket à récupérer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict avec les champs du ticket + items: list[dict], ou None si introuvable.
|
||||||
|
"""
|
||||||
|
receipt = conn.execute(
|
||||||
|
"SELECT id, store, date, total, delivery_fee, order_id FROM receipts WHERE id = ?",
|
||||||
|
(receipt_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if receipt is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name_raw, name_normalized, category, quantity, unit, unit_price, total_price
|
||||||
|
FROM items
|
||||||
|
WHERE receipt_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(receipt_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": receipt["id"],
|
||||||
|
"store": receipt["store"],
|
||||||
|
"date": receipt["date"],
|
||||||
|
"total": receipt["total"],
|
||||||
|
"delivery_fee": receipt["delivery_fee"],
|
||||||
|
"order_id": receipt["order_id"],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": i["id"],
|
||||||
|
"name_raw": i["name_raw"],
|
||||||
|
"name_normalized": i["name_normalized"],
|
||||||
|
"category": i["category"],
|
||||||
|
"quantity": i["quantity"],
|
||||||
|
"unit": i["unit"],
|
||||||
|
"unit_price": i["unit_price"],
|
||||||
|
"total_price": i["total_price"],
|
||||||
|
}
|
||||||
|
for i in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_product_list(conn: sqlite3.Connection) -> list[str]:
|
||||||
|
"""Liste tous les noms normalisés distincts (non NULL) pour le sélecteur.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de str triée alphabétiquement.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT name_normalized
|
||||||
|
FROM items
|
||||||
|
WHERE name_normalized IS NOT NULL
|
||||||
|
ORDER BY name_normalized
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [r["name_normalized"] for r in rows]
|
||||||
48
tickettracker/web/static/style.css
Normal file
48
tickettracker/web/static/style.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* Personnalisations légères par-dessus Pico CSS */
|
||||||
|
|
||||||
|
/* Grille de cartes statistiques : 2 colonnes min, 4 max */
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cartes avec le grand chiffre mis en avant */
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--pico-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contraindre la hauteur des canvas Chart.js */
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
max-height: 350px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Couleurs pour les écarts de prix dans la table compare */
|
||||||
|
.diff-positive {
|
||||||
|
color: #c0392b; /* rouge = Leclerc plus cher */
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-negative {
|
||||||
|
color: #27ae60; /* vert = Picnic plus cher (économie) */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Débordement horizontal pour les grandes tables */
|
||||||
|
.overflow-auto {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
36
tickettracker/web/templates/base.html
Normal file
36
tickettracker/web/templates/base.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}TicketTracker{% endblock %}</title>
|
||||||
|
<!-- Pico CSS : framework CSS minimaliste sans JavaScript -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<!-- Chart.js : graphiques -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<!-- Style personnalisé -->
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="container">
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><strong>🛒 TicketTracker</strong></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Accueil</a></li>
|
||||||
|
<li><a href="/compare">Comparer</a></li>
|
||||||
|
<li><a href="/api/docs" target="_blank">API docs</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container">
|
||||||
|
<small>TicketTracker — dashboard lecture seule</small>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
tickettracker/web/templates/compare.html
Normal file
62
tickettracker/web/templates/compare.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Comparer les prix — TicketTracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Comparaison Picnic vs Leclerc</h1>
|
||||||
|
|
||||||
|
{% if empty %}
|
||||||
|
<article>
|
||||||
|
<p>
|
||||||
|
Aucun produit commun trouvé entre Picnic et Leclerc.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Pour voir une comparaison, vous devez :
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>Importer des tickets des deux enseignes</li>
|
||||||
|
<li>Normaliser les noms d'articles avec la CLI</li>
|
||||||
|
</ol>
|
||||||
|
<pre><code>python -m tickettracker.cli normalize</code></pre>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<p>Produits présents chez les deux enseignes, triés par écart de prix décroissant.</p>
|
||||||
|
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Produit</th>
|
||||||
|
<th>Picnic moy.</th>
|
||||||
|
<th>Leclerc moy.</th>
|
||||||
|
<th>Écart €</th>
|
||||||
|
<th>Écart %</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td>{{ "%.2f"|format(p.price_picnic) }} €</td>
|
||||||
|
<td>{{ "%.2f"|format(p.price_leclerc) }} €</td>
|
||||||
|
<td class="{% if p.diff > 0 %}diff-positive{% elif p.diff < 0 %}diff-negative{% endif %}">
|
||||||
|
{{ "%+.2f"|format(p.diff) }} €
|
||||||
|
</td>
|
||||||
|
<td class="{% if p.diff > 0 %}diff-positive{% elif p.diff < 0 %}diff-negative{% endif %}">
|
||||||
|
{{ "%+.1f"|format(p.diff_pct) }} %
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/product/{{ p.name | urlquote }}">Historique</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><small>Positif = Leclerc plus cher, négatif = Picnic plus cher.</small></p>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
104
tickettracker/web/templates/index.html
Normal file
104
tickettracker/web/templates/index.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Accueil — TicketTracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Tableau de bord</h1>
|
||||||
|
|
||||||
|
{% if empty %}
|
||||||
|
<article>
|
||||||
|
<p>
|
||||||
|
Aucun ticket importé. Utilisez la CLI pour importer vos tickets :
|
||||||
|
</p>
|
||||||
|
<pre><code>python -m tickettracker.cli import chemin/vers/ticket.html # Picnic
|
||||||
|
python -m tickettracker.cli import chemin/vers/ticket.pdf # Leclerc</code></pre>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Cartes de statistiques -->
|
||||||
|
<div class="stat-grid">
|
||||||
|
<article class="stat-card">
|
||||||
|
<h3>{{ stats.total_receipts }}</h3>
|
||||||
|
<p>Tickets importés</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h3>{{ "%.2f"|format(stats.total_spent) }} €</h3>
|
||||||
|
<p>Total dépensé</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h3>{{ stats.distinct_products }}</h3>
|
||||||
|
<p>Produits distincts</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h3>{{ stats.total_items }}</h3>
|
||||||
|
<p>Articles scannés</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Graphique dépenses par mois -->
|
||||||
|
<article>
|
||||||
|
<h2>Dépenses par mois</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="monthlyChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const data = {{ chart_data | tojson }};
|
||||||
|
const ctx = document.getElementById("monthlyChart").getContext("2d");
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: "top" }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) { return value + " €"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Tableau des derniers tickets -->
|
||||||
|
<article>
|
||||||
|
<h2>Derniers tickets</h2>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Enseigne</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Articles</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in receipts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.id }}</td>
|
||||||
|
<td>{{ r.store }}</td>
|
||||||
|
<td>{{ r.date }}</td>
|
||||||
|
<td>{{ "%.2f"|format(r.total) }} €</td>
|
||||||
|
<td>{{ r.nb_items }}</td>
|
||||||
|
<td><a href="/receipt/{{ r.id }}">Voir</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
118
tickettracker/web/templates/product.html
Normal file
118
tickettracker/web/templates/product.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% if data %}{{ data.name }}{% else %}Produit inconnu{% endif %} — TicketTracker
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Historique produit</h1>
|
||||||
|
|
||||||
|
<!-- Sélecteur de produit : navigation JS vers /product/<nom encodé> -->
|
||||||
|
<label for="product-select">Choisir un produit :</label>
|
||||||
|
<select id="product-select" onchange="window.location='/product/' + encodeURIComponent(this.value)">
|
||||||
|
<option value="">— sélectionner —</option>
|
||||||
|
{% for p in all_products %}
|
||||||
|
<option value="{{ p }}" {% if p == name %}selected{% endif %}>{{ p }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{% if empty %}
|
||||||
|
<article>
|
||||||
|
<p>Produit <strong>{{ name }}</strong> introuvable dans la base.</p>
|
||||||
|
<p>Le nom doit correspondre exactement à un <code>name_normalized</code> existant.</p>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Cartes statistiques -->
|
||||||
|
<div class="stat-grid">
|
||||||
|
<article class="stat-card">
|
||||||
|
<h3>{{ "%.2f"|format(data.min_price) }} €</h3>
|
||||||
|
<p>Prix minimum</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h3>{{ "%.2f"|format(data.avg_price) }} €</h3>
|
||||||
|
<p>Prix moyen</p>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<h3>{{ "%.2f"|format(data.max_price) }} €</h3>
|
||||||
|
<p>Prix maximum</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Graphique d'historique des prix -->
|
||||||
|
<article>
|
||||||
|
<h2>Évolution du prix unitaire</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="priceChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const history = {{ data.history | tojson }};
|
||||||
|
|
||||||
|
// Couleurs par enseigne
|
||||||
|
const colors = { picnic: "#4a9eff", leclerc: "#ff6b35" };
|
||||||
|
const stores = [...new Set(history.map(h => h.store))].sort();
|
||||||
|
|
||||||
|
const datasets = stores.map(store => {
|
||||||
|
const points = history.filter(h => h.store === store);
|
||||||
|
return {
|
||||||
|
label: store,
|
||||||
|
data: points.map(h => ({ x: h.date, y: h.unit_price })),
|
||||||
|
borderColor: colors[store] || "#888",
|
||||||
|
backgroundColor: (colors[store] || "#888") + "33",
|
||||||
|
tension: 0.2,
|
||||||
|
fill: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById("priceChart").getContext("2d"), {
|
||||||
|
type: "line",
|
||||||
|
data: { datasets },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: { type: "category", title: { display: true, text: "Date" } },
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: "Prix unitaire (€)" },
|
||||||
|
ticks: { callback: v => v + " €" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: { legend: { position: "top" } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Tableau des occurrences -->
|
||||||
|
<article>
|
||||||
|
<h2>Toutes les occurrences</h2>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Enseigne</th>
|
||||||
|
<th>Prix unitaire</th>
|
||||||
|
<th>Quantité</th>
|
||||||
|
<th>Unité</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in data.history %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ h.date }}</td>
|
||||||
|
<td>{{ h.store }}</td>
|
||||||
|
<td>{{ "%.2f"|format(h.unit_price) }} €</td>
|
||||||
|
<td>{{ h.quantity }}</td>
|
||||||
|
<td>{{ h.unit }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
89
tickettracker/web/templates/receipt.html
Normal file
89
tickettracker/web/templates/receipt.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if data %}Ticket #{{ data.id }}{% else %}Ticket introuvable{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if data is none %}
|
||||||
|
<article>
|
||||||
|
<h2>Ticket introuvable</h2>
|
||||||
|
<p>Le ticket #{{ receipt_id }} n'existe pas dans la base.</p>
|
||||||
|
<a href="/">← Retour à l'accueil</a>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- En-tête ticket -->
|
||||||
|
<hgroup>
|
||||||
|
<h1>Ticket #{{ data.id }}</h1>
|
||||||
|
<p>{{ data.store | capitalize }} — {{ data.date }}</p>
|
||||||
|
</hgroup>
|
||||||
|
|
||||||
|
<!-- Champs du ticket -->
|
||||||
|
<article>
|
||||||
|
<dl>
|
||||||
|
<dt>Enseigne</dt>
|
||||||
|
<dd>{{ data.store }}</dd>
|
||||||
|
|
||||||
|
<dt>Date</dt>
|
||||||
|
<dd>{{ data.date }}</dd>
|
||||||
|
|
||||||
|
<dt>Total</dt>
|
||||||
|
<dd><strong>{{ "%.2f"|format(data.total) }} €</strong></dd>
|
||||||
|
|
||||||
|
{% if data.delivery_fee is not none %}
|
||||||
|
<dt>Frais de livraison</dt>
|
||||||
|
<dd>{{ "%.2f"|format(data.delivery_fee) }} €</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.order_id %}
|
||||||
|
<dt>Référence commande</dt>
|
||||||
|
<dd><code>{{ data.order_id }}</code></dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Articles -->
|
||||||
|
<article>
|
||||||
|
<h2>Articles ({{ data['items'] | length }})</h2>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom brut</th>
|
||||||
|
<th>Nom normalisé</th>
|
||||||
|
<th>Catégorie</th>
|
||||||
|
<th>Qté</th>
|
||||||
|
<th>Unité</th>
|
||||||
|
<th>Prix unit.</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in data['items'] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name_raw }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.name_normalized %}
|
||||||
|
<a href="/product/{{ item.name_normalized | urlquote }}">
|
||||||
|
{{ item.name_normalized }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<em>—</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.category or "—" }}</td>
|
||||||
|
<td>{{ item.quantity }}</td>
|
||||||
|
<td>{{ item.unit }}</td>
|
||||||
|
<td>{{ "%.2f"|format(item.unit_price) }} €</td>
|
||||||
|
<td>{{ "%.2f"|format(item.total_price) }} €</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<a href="/">← Retour à l'accueil</a>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user