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