#!/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()