Files
turf_saas/api_v1/routes/history.py
CTO H3R7Tech fac498efec
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
fix: test isolation + auth import compatibility + add optuna to requirements (HRT-136)
Test isolation fixes:
- auth_db.get_db(): read TURF_SAAS_DB dynamically (not frozen at import)
- api_v1/utils.get_db(): read TURF_SAAS_DB dynamically (not frozen at import)
- api_tokens_db.get_db(): read TURF_SAAS_DB dynamically (not frozen at import)
- tests/test_history.py: enforce _tmp_db.name + call init_auth_tables() in fixtures
- tests/test_user_tokens.py: enforce _tmp_db.name + call migrate_api_tokens_tables() in app fixture

Auth compatibility fixes:
- api_v1/routes/history.py: use auth.jwt_required_middleware (flask_jwt_extended)
  with saas_auth fallback for portal_server context
- api_v1/routes/ml_feedback.py: same auth import strategy
- api_v1/routes/user.py: same auth import strategy

Dependencies:
- requirements.txt: add optuna>=4.0.0 (used in ML ensemble tests and training)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-10 08:45:31 +02:00

217 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
History routes for API v1.
GET /api/v1/history — Historique des prédictions avec filtre date range,
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request, g
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
forbidden,
get_pagination_params,
paginate_query,
)
# Auth: try flask_jwt_extended (app_v1) first, fall back to saas_auth (portal_server)
try:
from auth import jwt_required_middleware
except ImportError:
from saas_auth import require_auth as jwt_required_middleware
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
# ──────────────────────────────────────────────────────────────
# Plan limits (days of history accessible; None = unlimited)
# ──────────────────────────────────────────────────────────────
HISTORY_DAYS = {
"free": 7,
"premium": 90,
"pro": None, # illimité
}
# Fallback for unknown plans: treat like free
_DEFAULT_LIMIT = 7
def _get_plan_max_days(plan: str):
"""Return the max history days allowed for the given plan, or default."""
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
def _parse_date(date_str: str, param_name: str):
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
raise ValueError(
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
)
# ──────────────────────────────────────────────────────────────
# GET /api/v1/history
# ──────────────────────────────────────────────────────────────
@history_bp.route("", methods=["GET"])
@jwt_required_middleware
def get_history():
"""
Historique des prédictions ML avec filtre date range
---
tags:
- Historique
summary: |
Historique des prédictions sur une plage de dates.
Limite selon le plan :
- Free : 7 derniers jours
- Premium : 90 derniers jours
- Pro : illimité
security:
- Bearer: []
parameters:
- name: start
in: query
type: string
format: date
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
- name: end
in: query
type: string
format: date
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
- name: limit
in: query
type: integer
default: 50
description: Nombre de résultats par page (max 500)
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Historique des prédictions ML
400:
description: Paramètre de date invalide
401:
description: Token invalide ou manquant
403:
description: Plage de dates hors limite du plan — upgrade requis
"""
user = getattr(request, "current_user", None) or getattr(g, "current_user", None)
if not user:
return jsonify({"error": "Non authentifié"}), 401
plan = user.get("plan", "free")
today = datetime.now().date()
max_days = _get_plan_max_days(plan)
# ── Parse end date ────────────────────────────────────────
end_str = request.args.get("end", today.isoformat())
try:
end_date = _parse_date(end_str, "end")
except ValueError as exc:
return bad_request(str(exc))
# ── Parse start date ─────────────────────────────────────
if max_days is not None:
default_start = today - timedelta(days=max_days - 1)
else:
# Pro: default to 30 days back when no start provided
default_start = today - timedelta(days=29)
start_str = request.args.get("start", default_start.isoformat())
try:
start_date = _parse_date(start_str, "start")
except ValueError as exc:
return bad_request(str(exc))
# ── Validate ordering ─────────────────────────────────────
if start_date > end_date:
return bad_request(
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
)
# ── Enforce plan window ───────────────────────────────────
if max_days is not None:
earliest_allowed = today - timedelta(days=max_days - 1)
if start_date < earliest_allowed:
return forbidden(
message=(
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
f"Passez à un plan supérieur pour accéder à un historique plus long."
),
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
current_plan=plan,
)
# ── Pagination ────────────────────────────────────────────
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
# ── Query ─────────────────────────────────────────────────
conn = get_db()
try:
if not table_exists(conn, "ml_predictions_cache"):
return jsonify(
{
"status": "ok",
"plan": plan,
"start": start_date.isoformat(),
"end": end_date.isoformat(),
"history": [],
**paginate_query([], 0, limit, offset),
}
), 200
count_row = conn.execute(
"""SELECT COUNT(*) as cnt
FROM ml_predictions_cache
WHERE date >= ? AND date <= ?""",
(start_date.isoformat(), end_date.isoformat()),
).fetchone()
total = count_row["cnt"] if count_row else 0
sql = """
SELECT
id, date, horse_name, prob_top1, prob_top3,
ml_score, race_label, hippodrome, heure, is_value_bet
FROM ml_predictions_cache
WHERE date >= ? AND date <= ?
ORDER BY date DESC, ml_score DESC
LIMIT ? OFFSET ?
"""
rows = conn.execute(
sql,
(start_date.isoformat(), end_date.isoformat(), limit, offset),
).fetchall()
history = [dict(r) for r in rows]
return jsonify(
{
"status": "ok",
"plan": plan,
"history_limit_days": max_days,
"start": start_date.isoformat(),
"end": end_date.isoformat(),
"history": history,
**paginate_query(history, total, limit, offset),
}
), 200
except Exception as exc:
return internal_error(str(exc))
finally:
conn.close()