feat: Sprint 3-4 — Refacto API /v1/ (HRT-29)

- Blueprint Flask api_v1 avec prefix /api/v1/
- GET /api/v1/health — healthcheck public
- GET /api/v1/courses/today — courses du jour (paginé, filtré)
- GET /api/v1/courses/{id}/predictions — prédictions ML pour une course
- GET /api/v1/predictions/top3 — top 3 global (free tier)
- GET /api/v1/predictions/all — toutes prédictions (premium+)
- GET /api/v1/valuebets — value bets du jour (premium+)
- GET /api/v1/backtest — résultats backtest historiques (pro)
- GET /api/v1/export/csv — export CSV prédictions/paris (pro)
- GET /api/v1/metrics — métriques perf ML (premium+)
- Swagger/OpenAPI via flasgger à /api/v1/docs
- Erreurs uniformes {status, message, code}
- Pagination limit/offset sur toutes les listes
- 42 tests d'intégration passants

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DevOps Engineer
2026-04-25 18:00:54 +02:00
parent c8f1bfd478
commit b8ef1ed35d
14 changed files with 2691 additions and 0 deletions

98
api_v1/utils.py Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Shared utilities for API v1 — error helpers, pagination, DB access.
"""
import sqlite3
import os
from flask import jsonify, request
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
# ──────────────────────────────────────────────────────────────
# Database
# ──────────────────────────────────────────────────────────────
def get_db():
"""Return a SQLite connection with Row factory."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def table_exists(conn, table_name: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
).fetchone()
return row is not None
# ──────────────────────────────────────────────────────────────
# Uniform error responses
# ──────────────────────────────────────────────────────────────
def error_response(message: str, code: int, status: str = "error"):
"""Return a JSON error envelope consistent with the API contract.
Shape: {"status": "error", "message": "...", "code": 400}
"""
return jsonify({"status": status, "message": message, "code": code}), code
def not_found(message: str = "Resource not found"):
return error_response(message, 404)
def bad_request(message: str = "Bad request"):
return error_response(message, 400)
def forbidden(message: str = "Forbidden", required_plans=None, current_plan=None):
payload = {"status": "error", "message": message, "code": 403}
if required_plans:
payload["required_plans"] = required_plans
if current_plan:
payload["current_plan"] = current_plan
payload["upgrade_url"] = "/api/v1/subscription/upgrade"
return jsonify(payload), 403
def internal_error(message: str = "Internal server error"):
return error_response(message, 500)
# ──────────────────────────────────────────────────────────────
# Pagination helpers
# ──────────────────────────────────────────────────────────────
def get_pagination_params(default_limit: int = 20, max_limit: int = 100):
"""Extract and validate limit/offset from query-string."""
try:
limit = int(request.args.get("limit", default_limit))
except (ValueError, TypeError):
limit = default_limit
try:
offset = int(request.args.get("offset", 0))
except (ValueError, TypeError):
offset = 0
limit = max(1, min(limit, max_limit))
offset = max(0, offset)
return limit, offset
def paginate_query(rows, total: int, limit: int, offset: int):
"""Wrap a list of rows in a pagination envelope."""
return {
"pagination": {
"total": total,
"limit": limit,
"offset": offset,
"has_more": (offset + limit) < total,
}
}