- New api_v1/routes/admin.py: admin client management blueprint - admin_users table for admin role (no ALTER TABLE needed) - require_admin decorator for endpoint protection - GET/PUT/DELETE /api/v1/admin/clients/<id> - POST /api/v1/admin/setup (first-time admin init) - POST /api/v1/admin/clients/<id>/suspend|activate - GET /api/v1/admin/stats (client counts by plan) - Registered in api_v1/__init__: auto-wired into portal_server.py - No new service, no merge tables, no ALTER TABLE
214 lines
7.7 KiB
Python
214 lines
7.7 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)
|
|
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()
|