From 912507b1d9b74c28c4f71e636063a55edee2a51c Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 13 May 2026 14:31:22 +0200 Subject: [PATCH] API: compteur de consommation SQLite asynchrone (#78) Co-Authored-By: Claude Sonnet 4.6 --- src/api/__init__.py | 6 ++++ src/api/routes.py | 11 ++++++- src/api/tracking.py | 67 ++++++++++++++++++++++++++++++++++++++ tests/api/conftest.py | 5 +-- tests/api/test_tracking.py | 36 ++++++++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/api/tracking.py create mode 100644 tests/api/test_tracking.py diff --git a/src/api/__init__.py b/src/api/__init__.py index 4dd2e5b..e68f48b 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -18,3 +18,9 @@ def init_api(server) -> None: api = Api(server) api.register_blueprint(routes.bp) + + import os + + from src.api import tracking + + tracking.start_worker(os.environ["USERS_DB_PATH"]) diff --git a/src/api/routes.py b/src/api/routes.py index 0d5d4a8..cce2167 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -1,6 +1,7 @@ -from flask import request +from flask import g, request from flask_smorest import Blueprint, abort +from src.api import tracking from src.api.auth import require_token from src.api.filters import FilterError, build_where from src.db import count_marches, query_marches @@ -62,6 +63,14 @@ def _build_links(page, page_size, total): return {"prev": prev_url, "next": next_url} +@bp.after_request +def _track_consumption(response): + token_id = getattr(g, "token_id", None) + if token_id is not None: + tracking.enqueue_counter_update(token_id) + return response + + @bp.route("/health") def health(): """Sonde de santé, sans authentification.""" diff --git a/src/api/tracking.py b/src/api/tracking.py new file mode 100644 index 0000000..00d25c5 --- /dev/null +++ b/src/api/tracking.py @@ -0,0 +1,67 @@ +import queue +import threading +from typing import Optional + +from src.api import tokens_db +from src.utils import logger + +_STOP_SENTINEL = object() +_queue: Optional[queue.Queue] = None +_worker_thread: Optional[threading.Thread] = None +_db_path: Optional[str] = None + + +def _worker_loop(q: queue.Queue, db_path: str) -> None: + while True: + item = q.get() + try: + if item is _STOP_SENTINEL: + return + kind, payload = item + if kind == "counter": + token_id = payload + try: + tokens_db.increment_usage(db_path, token_id) + except Exception: # noqa: BLE001 + logger.warning( + "tracking: échec increment_usage token_id=%s", + token_id, + exc_info=True, + ) + finally: + q.task_done() + + +def start_worker(db_path: str) -> None: + global _queue, _worker_thread, _db_path + if _worker_thread is not None and _worker_thread.is_alive(): + return + _db_path = db_path + _queue = queue.Queue() + _worker_thread = threading.Thread( + target=_worker_loop, args=(_queue, db_path), daemon=True + ) + _worker_thread.start() + + +def stop_worker() -> None: + global _worker_thread, _queue + if _worker_thread is None: + return + _queue.put(_STOP_SENTINEL) + _worker_thread.join(timeout=2.0) + _worker_thread = None + _queue = None + + +def enqueue_counter_update(token_id: int) -> None: + if _queue is None: + return # tracking désactivé (tests par ex.) + _queue.put(("counter", token_id)) + + +def flush(timeout: float = 2.0) -> None: + """Attend que la queue soit drainée. Utile en test.""" + if _queue is None: + return + _queue.join() diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 67dc109..5a83507 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -19,12 +19,13 @@ def api_client(monkeypatch, tmp_path): monkeypatch.setenv("USERS_DB_PATH", str(db_path)) from flask import Flask - from src.api import init_api, tokens_db + from src.api import init_api, tokens_db, tracking tokens_db.init_schema(db_path) server = Flask(__name__) init_api(server) - return server.test_client(), db_path + yield server.test_client(), db_path + tracking.stop_worker() @pytest.fixture diff --git a/tests/api/test_tracking.py b/tests/api/test_tracking.py new file mode 100644 index 0000000..dd0a662 --- /dev/null +++ b/tests/api/test_tracking.py @@ -0,0 +1,36 @@ +from src.api import tokens_db, tracking + + +def test_counter_worker_increments_count(temp_db): + _, token_id = tokens_db.create_token(temp_db, "x") + + tracking.start_worker(str(temp_db)) + try: + tracking.enqueue_counter_update(token_id) + tracking.enqueue_counter_update(token_id) + # Laisser le worker drainer la queue + tracking.flush(timeout=2.0) + finally: + tracking.stop_worker() + + rows = tokens_db.list_tokens(temp_db) + assert rows[0]["count_total"] == 2 + assert rows[0]["last_used_at"] is not None + + +def test_after_request_hook_increments_counter_async(api_client, valid_token_header): + client, db_path = api_client + # Récupérer le token_id du token créé par la fixture + from src.api import tokens_db, tracking + + rows = tokens_db.list_tokens(db_path) + assert len(rows) == 1 # vérification du token créé par la fixture + + # Faire une requête (qui doit déclencher l'incrément) + client.get("/api/v1/health") # pas authentifiée → ne compte pas + client.get("/api/v1/schema", headers=valid_token_header) + + tracking.flush(timeout=2.0) + + rows = tokens_db.list_tokens(db_path) + assert rows[0]["count_total"] == 1