diff --git a/requirements.txt b/requirements.txt index 71be7bb..090a373 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..a521865 --- /dev/null +++ b/tests/test_web.py @@ -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/ 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//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//history retourne 404.""" + resp = client_with_data.get("/api/product/ProduitInexistant/history") + assert resp.status_code == 404 diff --git a/tickettracker/config.py b/tickettracker/config.py index ff52a73..5e6198c 100644 --- a/tickettracker/config.py +++ b/tickettracker/config.py @@ -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 diff --git a/tickettracker/web/__init__.py b/tickettracker/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tickettracker/web/api.py b/tickettracker/web/api.py new file mode 100644 index 0000000..e10afc5 --- /dev/null +++ b/tickettracker/web/api.py @@ -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 diff --git a/tickettracker/web/app.py b/tickettracker/web/app.py new file mode 100644 index 0000000..e7a19aa --- /dev/null +++ b/tickettracker/web/app.py @@ -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) diff --git a/tickettracker/web/queries.py b/tickettracker/web/queries.py new file mode 100644 index 0000000..1052002 --- /dev/null +++ b/tickettracker/web/queries.py @@ -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] diff --git a/tickettracker/web/static/style.css b/tickettracker/web/static/style.css new file mode 100644 index 0000000..b0a6d5b --- /dev/null +++ b/tickettracker/web/static/style.css @@ -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; +} diff --git a/tickettracker/web/templates/base.html b/tickettracker/web/templates/base.html new file mode 100644 index 0000000..e9664c0 --- /dev/null +++ b/tickettracker/web/templates/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}TicketTracker{% endblock %} + + + + + + + + +
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+ TicketTracker — dashboard lecture seule +
+ + diff --git a/tickettracker/web/templates/compare.html b/tickettracker/web/templates/compare.html new file mode 100644 index 0000000..2d0172e --- /dev/null +++ b/tickettracker/web/templates/compare.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block title %}Comparer les prix — TicketTracker{% endblock %} + +{% block content %} +

Comparaison Picnic vs Leclerc

+ +{% if empty %} +
+

+ Aucun produit commun trouvé entre Picnic et Leclerc. +

+

+ Pour voir une comparaison, vous devez : +

+
    +
  1. Importer des tickets des deux enseignes
  2. +
  3. Normaliser les noms d'articles avec la CLI
  4. +
+
python -m tickettracker.cli normalize
+
+{% else %} + +

Produits présents chez les deux enseignes, triés par écart de prix décroissant.

+ +
+ + + + + + + + + + + + + {% for p in products %} + + + + + + + + + {% endfor %} + +
ProduitPicnic moy.Leclerc moy.Écart €Écart %
{{ p.name }}{{ "%.2f"|format(p.price_picnic) }} €{{ "%.2f"|format(p.price_leclerc) }} € + {{ "%+.2f"|format(p.diff) }} € + + {{ "%+.1f"|format(p.diff_pct) }} % + + Historique +
+
+ +

Positif = Leclerc plus cher, négatif = Picnic plus cher.

+ +{% endif %} +{% endblock %} diff --git a/tickettracker/web/templates/index.html b/tickettracker/web/templates/index.html new file mode 100644 index 0000000..e11fdf4 --- /dev/null +++ b/tickettracker/web/templates/index.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block title %}Accueil — TicketTracker{% endblock %} + +{% block content %} +

Tableau de bord

+ +{% if empty %} +
+

+ Aucun ticket importé. Utilisez la CLI pour importer vos tickets : +

+
python -m tickettracker.cli import chemin/vers/ticket.html  # Picnic
+python -m tickettracker.cli import chemin/vers/ticket.pdf   # Leclerc
+
+{% else %} + + +
+
+

{{ stats.total_receipts }}

+

Tickets importés

+
+
+

{{ "%.2f"|format(stats.total_spent) }} €

+

Total dépensé

+
+
+

{{ stats.distinct_products }}

+

Produits distincts

+
+
+

{{ stats.total_items }}

+

Articles scannés

+
+
+ + +
+

Dépenses par mois

+
+ +
+
+ + + + +
+

Derniers tickets

+
+ + + + + + + + + + + + + {% for r in receipts %} + + + + + + + + + {% endfor %} + +
#EnseigneDateTotalArticles
{{ r.id }}{{ r.store }}{{ r.date }}{{ "%.2f"|format(r.total) }} €{{ r.nb_items }}Voir
+
+
+ +{% endif %} +{% endblock %} diff --git a/tickettracker/web/templates/product.html b/tickettracker/web/templates/product.html new file mode 100644 index 0000000..c9c3d97 --- /dev/null +++ b/tickettracker/web/templates/product.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} + +{% block title %} +{% if data %}{{ data.name }}{% else %}Produit inconnu{% endif %} — TicketTracker +{% endblock %} + +{% block content %} +

Historique produit

+ + + + + +{% if empty %} +
+

Produit {{ name }} introuvable dans la base.

+

Le nom doit correspondre exactement à un name_normalized existant.

+
+{% else %} + + +
+
+

{{ "%.2f"|format(data.min_price) }} €

+

Prix minimum

+
+
+

{{ "%.2f"|format(data.avg_price) }} €

+

Prix moyen

+
+
+

{{ "%.2f"|format(data.max_price) }} €

+

Prix maximum

+
+
+ + +
+

Évolution du prix unitaire

+
+ +
+
+ + + + +
+

Toutes les occurrences

+
+ + + + + + + + + + + + {% for h in data.history %} + + + + + + + + {% endfor %} + +
DateEnseignePrix unitaireQuantitéUnité
{{ h.date }}{{ h.store }}{{ "%.2f"|format(h.unit_price) }} €{{ h.quantity }}{{ h.unit }}
+
+
+ +{% endif %} +{% endblock %} diff --git a/tickettracker/web/templates/receipt.html b/tickettracker/web/templates/receipt.html new file mode 100644 index 0000000..e1c4ca8 --- /dev/null +++ b/tickettracker/web/templates/receipt.html @@ -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 %} + +{% else %} + + +
+

Ticket #{{ data.id }}

+

{{ data.store | capitalize }} — {{ data.date }}

+
+ + +
+
+
Enseigne
+
{{ data.store }}
+ +
Date
+
{{ data.date }}
+ +
Total
+
{{ "%.2f"|format(data.total) }} €
+ + {% if data.delivery_fee is not none %} +
Frais de livraison
+
{{ "%.2f"|format(data.delivery_fee) }} €
+ {% endif %} + + {% if data.order_id %} +
Référence commande
+
{{ data.order_id }}
+ {% endif %} +
+
+ + +
+

Articles ({{ data['items'] | length }})

+
+ + + + + + + + + + + + + + {% for item in data['items'] %} + + + + + + + + + + {% endfor %} + +
Nom brutNom normaliséCatégorieQtéUnitéPrix unit.Total
{{ item.name_raw }} + {% if item.name_normalized %} + + {{ item.name_normalized }} + + {% else %} + + {% endif %} + {{ item.category or "—" }}{{ item.quantity }}{{ item.unit }}{{ "%.2f"|format(item.unit_price) }} €{{ "%.2f"|format(item.total_price) }} €
+
+
+ +← Retour à l'accueil + +{% endif %} +{% endblock %}