Compare commits

...

4 Commits

Author SHA1 Message Date
DevOps Engineer
ce0ee150ec fix(api-v1): add billing_db.py dependency for billing routes
The api_v1 Blueprint includes billing routes (POST/GET /api/v1/billing/*),
which import from billing_db. This module lives in feature/billing-stripe
(HRT-31) but is needed here for tests to pass. Added the file so all
42 integration tests pass without modification.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 18:08:39 +02:00
DevOps Engineer
b8ef1ed35d 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>
2026-04-25 18:00:54 +02:00
DevOps Engineer
c8f1bfd478 Merge feature/auth-jwt-multitenant into main — Sprint 2-3 Auth JWT + Multi-tenant (HRT-28)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 17:35:48 +02:00
DevOps Engineer
5a23692ad1 feat: Sprint 2-3 — Auth JWT + Multi-tenant (HRT-28)
- auth_db.py: create users, subscriptions, refresh_tokens tables in turf_saas.db
- auth.py: register/login/refresh/logout endpoints, JWT middleware, plan_required decorator, free daily-limit check
- middleware.py: in-memory rate limiter (100 req/min/IP), timestamped access logs
- saas_api.py: Flask app factory wiring JWT, CORS, blueprints, /api/v1/predictions plan-gating
- tests/test_auth.py: 27 pytest tests, 83% coverage (target >=80%)
- API_AUTH.md: full endpoint documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 17:35:45 +02:00
21 changed files with 4121 additions and 0 deletions

132
API_AUTH.md Normal file
View File

@@ -0,0 +1,132 @@
# API Auth JWT — Documentation
## Sprint 2-3 (HRT-28)
Base URL: `http://localhost:8792`
---
## Endpoints d'authentification
### `POST /api/v1/auth/register`
Inscription d'un nouvel utilisateur (plan free par défaut).
**Body JSON:**
```json
{ "email": "user@example.com", "password": "motdepasse123" }
```
**Réponse 201:**
```json
{ "message": "Compte créé avec succès", "user_id": 1 }
```
**Erreurs:** `400` (email invalide / mot de passe < 8 car.), `409` (email déjà utilisé)
---
### `POST /api/v1/auth/login`
Connexion — retourne access_token (15min) + refresh_token (30j).
**Body JSON:**
```json
{ "email": "user@example.com", "password": "motdepasse123" }
```
**Réponse 200:**
```json
{
"access_token": "<JWT>",
"refresh_token": "<refresh_JWT>",
"token_type": "Bearer",
"plan": "free"
}
```
---
### `POST /api/v1/auth/refresh`
Rotation du refresh token — invalide l'ancien, émet un nouveau.
**Body JSON:**
```json
{ "refresh_token": "<refresh_JWT>" }
```
**Réponse 200:** identique à `/login`
---
### `POST /api/v1/auth/logout`
Révocation du refresh token.
**Body JSON:**
```json
{ "refresh_token": "<refresh_JWT>" }
```
**Réponse 200:**
```json
{ "message": "Déconnexion réussie" }
```
---
## Routes protégées
Toutes les routes protégées nécessitent le header:
```
Authorization: Bearer <access_token>
```
### `GET /api/v1/predictions`
| Plan | Accès |
|---------|---------------------------------------------|
| free | Top 3 uniquement, 1 course/jour |
| premium | Toutes les courses + alertes Telegram |
| pro | API complète + lien export CSV |
### `GET /api/v1/predictions/export`
Export CSV — **plan pro uniquement** (`403` pour free/premium).
### `GET /api/v1/subscription/upgrade`
Infos sur les plans disponibles et plan courant de l'utilisateur.
### `GET /api/v1/health`
Vérification d'état du service (pas d'auth requise).
---
## Sécurité
- **Passwords:** hashés avec bcrypt (saltRounds=12)
- **JWT access:** expiration 15 minutes (HS256)
- **JWT refresh:** expiration 30 jours, stocké hashé (SHA-256) en DB, rotation à chaque usage
- **Rate limiting:** 100 requêtes/min par IP — header `X-RateLimit-Remaining`
- **CORS:** configuré pour `https://turf-ia.h3r7.tech` + localhost dev
- **Logs d'accès:** horodatés ISO 8601 dans `logs/saas_api.log`
---
## Lancement
```bash
JWT_SECRET_KEY="votre_cle_secrete" \
CORS_ORIGINS="https://turf-ia.h3r7.tech" \
./venv/bin/python saas_api.py
```
---
## Tests
```bash
./venv/bin/pytest tests/test_auth.py -v
# Avec couverture:
./venv/bin/pytest tests/test_auth.py --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing
# Résultat: 27 tests OK, couverture globale 83%
```
---
## Structure des tables DB
```sql
-- users: id, email, password_hash, plan(free/premium/pro), created_at, is_active, daily_usage, last_usage_date
-- subscriptions: id, user_id, plan, start_date, end_date, stripe_customer_id
-- refresh_tokens: id, user_id, token_hash, created_at, expires_at, revoked
```

156
README_API_V1.md Normal file
View File

@@ -0,0 +1,156 @@
# Turf SaaS — API v1 Reference
Sprint 3-4 · HRT-29 — Refacto API /v1/
## Base URL
```
http://<host>:8792
```
## Authentication
All endpoints (except `/api/v1/health` and `/api/v1/auth/*`) require a **Bearer JWT** token.
```
Authorization: Bearer <access_token>
```
### Get a token
```bash
# Register
curl -X POST http://localhost:8792/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "mypassword"}'
# Login → returns access_token + refresh_token
curl -X POST http://localhost:8792/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "mypassword"}'
```
## Plans & Access Control
| Plan | Inclus |
|-----------|----------------------------------------------------|
| `free` | health, auth, courses/today, predictions/top3 (1/j)|
| `premium` | + predictions/all, valuebets, metrics |
| `pro` | + backtest, export/csv |
## Endpoints
### System
| Method | Path | Auth | Description |
|--------|------------------|------|----------------------|
| GET | `/api/v1/health` | Non | Healthcheck public |
| GET | `/api/v1/docs` | Non | Swagger UI |
### Auth
| Method | Path | Description |
|--------|---------------------------|--------------------------------|
| POST | `/api/v1/auth/register` | Créer un compte (plan=free) |
| POST | `/api/v1/auth/login` | Login → JWT tokens |
| POST | `/api/v1/auth/refresh` | Renouveler l'access token |
| POST | `/api/v1/auth/logout` | Révoquer le refresh token |
### Courses
| Method | Path | Plan | Description |
|--------|---------------------------------------|---------|------------------------------------|
| GET | `/api/v1/courses/today` | free+ | Courses du jour (paginé) |
| GET | `/api/v1/courses/{id}/predictions` | free+ | Prédictions ML pour une course |
Query params `courses/today`: `filter=[all|quinte|trot|plat]`, `limit`, `offset`
`{id}` format: `{num_reunion}-{num_course}` ex: `1-3`
### Prédictions
| Method | Path | Plan | Description |
|--------|---------------------------|-----------|------------------------------|
| GET | `/api/v1/predictions/top3`| free+ | Top 3 chevaux du jour |
| GET | `/api/v1/predictions/all` | premium+ | Toutes les prédictions ML |
Query params: `date=YYYY-MM-DD`, `limit`, `offset`
### Value Bets
| Method | Path | Plan | Description |
|--------|---------------------|-----------|--------------------------|
| GET | `/api/v1/valuebets` | premium+ | Value bets du jour |
Query params: `date`, `min_odds` (défaut 2.0), `limit`, `offset`
### Backtest
| Method | Path | Plan | Description |
|--------|---------------------|------|----------------------------------|
| GET | `/api/v1/backtest` | pro | Résultats historiques des paris |
Query params: `start`, `end` (YYYY-MM-DD), `limit`, `offset`
### Export
| Method | Path | Plan | Description |
|--------|-------------------------|------|----------------------|
| GET | `/api/v1/export/csv` | pro | Export CSV |
Query params: `type=[predictions|bets]`, `date`, `start`, `end`
### Métriques
| Method | Path | Plan | Description |
|--------|---------------------|----------|-----------------------|
| GET | `/api/v1/metrics` | premium+ | Métriques ML et paris |
Query params: `days` (int, défaut 30)
## Réponse uniforme
Toutes les erreurs retournent :
```json
{
"status": "error",
"message": "Description de l'erreur",
"code": 400
}
```
Les listes paginées incluent :
```json
{
"pagination": {
"total": 150,
"limit": 20,
"offset": 0,
"has_more": true
}
}
```
## Démarrage
```bash
cd /home/h3r7/turf_saas
source venv/bin/activate
python app_v1.py
# ou
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
```
## Tests
```bash
cd /home/h3r7/turf_saas
source venv/bin/activate
python -m pytest tests/test_api_v1.py -v
```
## Documentation Swagger
Accessible sur : `http://localhost:8792/api/v1/docs`

43
api_v1/__init__.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""
API v1 Blueprint package — Turf SaaS
Sprint 3-4: HRT-29 — Refacto API /v1/
Sprint 5-6: HRT-31 — Billing Stripe
Registers sub-blueprints:
/api/v1/health — public health-check
/api/v1/courses/ — courses du jour
/api/v1/predictions/— predictions ML
/api/v1/valuebets — value bets (premium+)
/api/v1/backtest — backtest historique (pro)
/api/v1/export/ — export CSV (pro)
/api/v1/metrics — métriques perf ML (premium+)
/api/v1/billing/ — Stripe checkout, portal, webhook, status
/api/v1/docs — Swagger UI (via flasgger, registered on app)
"""
from flask import Blueprint
from .routes.health import health_bp
from .routes.courses import courses_bp
from .routes.predictions import predictions_bp
from .routes.valuebets import valuebets_bp
from .routes.backtest import backtest_bp
from .routes.export import export_bp
from .routes.metrics import metrics_bp
from .routes.billing import billing_bp
# Master blueprint that aggregates all sub-routes under /api/v1
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
def register_api_v1(app):
"""Register all API v1 blueprints onto the Flask app."""
app.register_blueprint(health_bp)
app.register_blueprint(courses_bp)
app.register_blueprint(predictions_bp)
app.register_blueprint(valuebets_bp)
app.register_blueprint(backtest_bp)
app.register_blueprint(export_bp)
app.register_blueprint(metrics_bp)
app.register_blueprint(billing_bp)

View File

195
api_v1/routes/backtest.py Normal file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
Backtest route for API v1.
GET /api/v1/backtest — Résultats backtest historiques (pro)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, plan_required
backtest_bp = Blueprint("v1_backtest", __name__, url_prefix="/api/v1")
@backtest_bp.route("/backtest", methods=["GET"])
@jwt_required_middleware
@plan_required("pro")
def backtest():
"""
Backtest historique
---
tags:
- Backtest
summary: Résultats backtest historiques des paris simulés — accès pro uniquement
security:
- Bearer: []
parameters:
- name: start
in: query
type: string
format: date
description: Date de début (YYYY-MM-DD), défaut = -30j
- name: end
in: query
type: string
format: date
description: Date de fin (YYYY-MM-DD), défaut = aujourd'hui
- name: limit
in: query
type: integer
default: 50
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Résultats backtest
401:
description: Token invalide
403:
description: Plan insuffisant (pro requis)
"""
start = request.args.get("start")
end = request.args.get("end")
# Validate date formats
for label, val in [("start", start), ("end", end)]:
if val:
try:
datetime.strptime(val, "%Y-%m-%d")
except ValueError:
return bad_request(
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
)
if not start:
start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
if not end:
end = datetime.now().strftime("%Y-%m-%d")
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
conn = get_db()
try:
if not table_exists(conn, "bet_results"):
return jsonify(
{
"status": "ok",
"period": {"start": start, "end": end},
"summary": {
"total_bets": 0,
"message": "Aucune donnée bet_results",
},
"by_type": {},
"details": [],
"pagination": {
"total": 0,
"limit": limit,
"offset": offset,
"has_more": False,
},
}
), 200
# Summary
summary_row = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
SUM(mise) AS mise,
SUM(gain) AS gain
FROM bet_results
WHERE date BETWEEN ? AND ?""",
(start, end),
).fetchone()
total_bets = summary_row["total"] or 0
gagne = summary_row["gagne"] or 0
mise = float(summary_row["mise"] or 0)
gain = float(summary_row["gain"] or 0)
roi = round((gain - mise) / mise * 100, 1) if mise > 0 else 0.0
precision = round(gagne / total_bets * 100, 1) if total_bets > 0 else 0.0
# By type
by_type_rows = conn.execute(
"""SELECT
type_pari,
COUNT(*) AS total,
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
SUM(mise) AS mise,
SUM(gain) AS gain
FROM bet_results
WHERE date BETWEEN ? AND ?
GROUP BY type_pari""",
(start, end),
).fetchall()
by_type = {}
for row in by_type_rows:
t = row["total"] or 0
g = row["gagne"] or 0
m = float(row["mise"] or 0)
gn = float(row["gain"] or 0)
by_type[row["type_pari"]] = {
"count": t,
"gagne": g,
"mise": round(m, 2),
"gain": round(gn, 2),
"roi": round((gn - m) / m * 100, 1) if m > 0 else 0.0,
"precision": round(g / t * 100, 1) if t > 0 else 0.0,
}
# Paginated details
count_row = conn.execute(
"SELECT COUNT(*) AS cnt FROM bet_results WHERE date BETWEEN ? AND ?",
(start, end),
).fetchone()
detail_total = count_row["cnt"] if count_row else 0
detail_rows = conn.execute(
"""SELECT date, race_name, type_pari, horse_name, horse_number,
COALESCE(cote, 0) AS cote, mise, resultat, gain
FROM bet_results
WHERE date BETWEEN ? AND ?
ORDER BY date DESC, id DESC
LIMIT ? OFFSET ?""",
(start, end, limit, offset),
).fetchall()
details = [dict(r) for r in detail_rows]
pagination = paginate_query(details, detail_total, limit, offset)
return jsonify(
{
"status": "ok",
"period": {"start": start, "end": end},
"summary": {
"total_bets": total_bets,
"gagne": gagne,
"perdu": total_bets - gagne,
"precision": precision,
"mise_totale": round(mise, 2),
"gain_total": round(gain, 2),
"roi": roi,
},
"by_type": by_type,
"details": details,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

664
api_v1/routes/billing.py Normal file
View File

@@ -0,0 +1,664 @@
#!/usr/bin/env python3
"""
Billing Blueprint — Stripe integration
Sprint 5-6: HRT-31
Endpoints:
POST /api/v1/billing/checkout — create Stripe Checkout session (auth required)
POST /api/v1/billing/portal — create Stripe Customer Portal session (auth required)
POST /api/v1/billing/webhook — Stripe webhook handler (public, signature-verified)
GET /api/v1/billing/status — current subscription status (auth required)
Environment variables required:
STRIPE_SECRET_KEY — Stripe secret key (sk_live_... or sk_test_...)
STRIPE_PUBLISHABLE_KEY — Stripe publishable key (pk_...)
STRIPE_WEBHOOK_SECRET — webhook signing secret (whsec_...)
STRIPE_PRICE_PREMIUM — Stripe Price ID for Premium plan (price_...)
STRIPE_PRICE_PRO — Stripe Price ID for Pro plan (price_...)
APP_BASE_URL — e.g. https://turf-ia.h3r7.tech (default http://localhost:8793)
"""
import json
import logging
import os
from datetime import datetime, timedelta, timezone
import stripe
from flask import Blueprint, g, jsonify, request
from auth import jwt_required_middleware
from billing_db import get_db, migrate_billing_tables
logger = logging.getLogger("turf_saas.billing")
billing_bp = Blueprint("billing", __name__, url_prefix="/api/v1/billing")
# ──────────────────────────────────────────────────────────────
# Stripe configuration
# ──────────────────────────────────────────────────────────────
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:8793")
# Plan → Stripe Price ID mapping
PLAN_PRICE_IDS = {
"premium": os.environ.get("STRIPE_PRICE_PREMIUM", ""),
"pro": os.environ.get("STRIPE_PRICE_PRO", ""),
}
# Plan display names
PLAN_NAMES = {
"free": "Free",
"premium": "Premium",
"pro": "Pro",
}
# ──────────────────────────────────────────────────────────────
# DB helpers
# ──────────────────────────────────────────────────────────────
def _sget(obj, key, default=None):
"""Safely get a value from a dict OR a Stripe StripeObject.
Stripe v7+ uses attribute-style access; plain dicts use [] / .get().
"""
try:
# StripeObject supports [] but not .get(); dict supports both
val = obj[key]
return val if val is not None else default
except (KeyError, TypeError):
return default
def _get_active_subscription(db, user_id: int):
"""Return the most recent active subscription row for a user."""
return db.execute(
"""SELECT * FROM subscriptions
WHERE user_id = ?
ORDER BY start_date DESC
LIMIT 1""",
(user_id,),
).fetchone()
def _upsert_subscription(db, user_id: int, **fields):
"""
Update existing subscription or insert a new one.
fields: plan, stripe_customer_id, stripe_subscription_id,
status, current_period_end, grace_period_end, end_date
"""
existing = _get_active_subscription(db, user_id)
if existing:
# Build SET clause dynamically from provided fields
set_parts = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [existing["id"]]
db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values)
else:
cols = ", ".join(["user_id"] + list(fields.keys()))
placeholders = ", ".join(["?"] * (1 + len(fields)))
values = [user_id] + list(fields.values())
db.execute(
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
)
def _update_user_plan(db, user_id: int, plan: str):
"""Sync users.plan field to match active subscription."""
db.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
def _get_or_create_stripe_customer(user, db) -> str:
"""Return existing stripe_customer_id or create a new Stripe Customer."""
sub = _get_active_subscription(db, user["id"])
if sub and sub["stripe_customer_id"]:
return sub["stripe_customer_id"]
# Create new customer in Stripe
customer = stripe.Customer.create(
email=user["email"],
metadata={"user_id": str(user["id"])},
)
return customer["id"]
def _record_billing_event(
db, stripe_event_id: str, event_type: str, user_id=None, payload=None
):
"""Insert a billing_events audit row (idempotent on stripe_event_id)."""
try:
db.execute(
"""INSERT OR IGNORE INTO billing_events
(stripe_event_id, event_type, user_id, payload)
VALUES (?, ?, ?, ?)""",
(
stripe_event_id,
event_type,
user_id,
json.dumps(payload) if payload else None,
),
)
except Exception as e:
logger.warning("Could not record billing event %s: %s", stripe_event_id, e)
# ──────────────────────────────────────────────────────────────
# POST /api/v1/billing/checkout
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/checkout", methods=["POST"])
@jwt_required_middleware
def create_checkout():
"""
Create a Stripe Checkout session for upgrading to Premium or Pro.
---
tags:
- Billing
security:
- Bearer: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [plan]
properties:
plan:
type: string
enum: [premium, pro]
responses:
200:
description: Checkout session URL
schema:
type: object
properties:
checkout_url:
type: string
session_id:
type: string
400:
description: Invalid plan or Stripe not configured
503:
description: Stripe API error
"""
if not stripe.api_key:
return jsonify({"error": "Stripe non configuré"}), 503
body = request.get_json(silent=True) or {}
plan = body.get("plan", "").lower()
if plan not in ("premium", "pro"):
return jsonify({"error": "Plan invalide. Choisir 'premium' ou 'pro'"}), 400
price_id = PLAN_PRICE_IDS.get(plan)
if not price_id:
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
user = g.current_user
if user["plan"] == plan:
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
db = get_db()
try:
customer_id = _get_or_create_stripe_customer(user, db)
# Persist customer_id early to prevent duplicates
_upsert_subscription(
db, user["id"], stripe_customer_id=customer_id, plan=user["plan"]
)
db.commit()
session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=["card"],
line_items=[{"price": price_id, "quantity": 1}],
mode="subscription",
success_url=f"{APP_BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{APP_BASE_URL}/billing/cancel",
metadata={"user_id": str(user["id"]), "plan": plan},
subscription_data={"metadata": {"user_id": str(user["id"]), "plan": plan}},
)
except stripe.StripeError as e:
logger.error("Stripe checkout error for user %s: %s", user["id"], e)
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
finally:
db.close()
return jsonify(
{
"checkout_url": session.url,
"session_id": session.id,
"plan": plan,
"publishable_key": STRIPE_PUBLISHABLE_KEY,
}
), 200
# ──────────────────────────────────────────────────────────────
# POST /api/v1/billing/portal
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/portal", methods=["POST"])
@jwt_required_middleware
def create_portal():
"""
Create a Stripe Customer Portal session for managing subscription.
---
tags:
- Billing
security:
- Bearer: []
responses:
200:
description: Portal session URL
400:
description: No Stripe customer found
503:
description: Stripe not configured or API error
"""
if not stripe.api_key:
return jsonify({"error": "Stripe non configuré"}), 503
user = g.current_user
db = get_db()
try:
sub = _get_active_subscription(db, user["id"])
customer_id = sub["stripe_customer_id"] if sub else None
if not customer_id:
return jsonify(
{
"error": "Aucun abonnement Stripe trouvé. "
"Souscrivez d'abord à un plan payant."
}
), 400
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=f"{APP_BASE_URL}/account",
)
except stripe.StripeError as e:
logger.error("Stripe portal error for user %s: %s", user["id"], e)
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
finally:
db.close()
return jsonify({"portal_url": session.url}), 200
# ──────────────────────────────────────────────────────────────
# GET /api/v1/billing/status
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/status", methods=["GET"])
@jwt_required_middleware
def billing_status():
"""
Return current subscription status for the authenticated user.
---
tags:
- Billing
security:
- Bearer: []
responses:
200:
description: Subscription status
"""
user = g.current_user
db = get_db()
try:
sub = _get_active_subscription(db, user["id"])
finally:
db.close()
if not sub:
return jsonify(
{
"plan": "free",
"status": "active",
"stripe_customer_id": None,
"stripe_subscription_id": None,
"current_period_end": None,
"grace_period_end": None,
}
), 200
return jsonify(
{
"plan": sub["plan"],
"status": sub["status"] or "active",
"stripe_customer_id": sub["stripe_customer_id"],
"stripe_subscription_id": sub["stripe_subscription_id"],
"start_date": sub["start_date"],
"end_date": sub["end_date"],
"current_period_end": sub["current_period_end"],
"grace_period_end": sub["grace_period_end"],
}
), 200
# ──────────────────────────────────────────────────────────────
# POST /api/v1/billing/webhook
# ──────────────────────────────────────────────────────────────
@billing_bp.route("/webhook", methods=["POST"])
def stripe_webhook():
"""
Stripe webhook handler — no auth, signature-verified.
Handled events:
checkout.session.completed → activate subscription
customer.subscription.updated → sync plan/status
customer.subscription.deleted → downgrade to free
invoice.payment_failed → set past_due + 3-day grace period
invoice.payment_succeeded → clear grace period
"""
payload = request.get_data()
sig_header = request.headers.get("Stripe-Signature", "")
# Verify webhook signature (required in production)
if STRIPE_WEBHOOK_SECRET:
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except stripe.SignatureVerificationError as e:
logger.warning("Stripe webhook signature invalid: %s", e)
return jsonify({"error": "Signature invalide"}), 400
except ValueError as e:
logger.warning("Stripe webhook payload invalid: %s", e)
return jsonify({"error": "Payload invalide"}), 400
else:
# Dev/test: accept without verification (log a warning)
logger.warning("STRIPE_WEBHOOK_SECRET not set — skipping signature check!")
try:
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
except Exception as e:
return jsonify({"error": "Payload invalide", "detail": str(e)}), 400
event_type = event["type"]
event_id = event["id"]
logger.info("Stripe webhook received: %s (%s)", event_type, event_id)
db = get_db()
try:
if event_type == "checkout.session.completed":
_handle_checkout_completed(db, event)
elif event_type in (
"customer.subscription.updated",
"customer.subscription.created",
):
_handle_subscription_updated(db, event)
elif event_type == "customer.subscription.deleted":
_handle_subscription_deleted(db, event)
elif event_type == "invoice.payment_failed":
_handle_payment_failed(db, event)
elif event_type == "invoice.payment_succeeded":
_handle_payment_succeeded(db, event)
else:
logger.debug("Unhandled Stripe event type: %s", event_type)
db.commit()
except Exception as e:
db.rollback()
logger.error("Error processing Stripe webhook %s: %s", event_id, e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
return jsonify({"status": "ok"}), 200
# ──────────────────────────────────────────────────────────────
# Webhook handlers
# ──────────────────────────────────────────────────────────────
def _resolve_user_from_customer(db, customer_id: str):
"""Look up user_id via subscriptions.stripe_customer_id."""
row = db.execute(
"SELECT user_id FROM subscriptions WHERE stripe_customer_id = ? LIMIT 1",
(customer_id,),
).fetchone()
if row:
return row["user_id"]
# Fallback: query Stripe for user_id metadata
try:
customer = stripe.Customer.retrieve(customer_id)
meta = _sget(customer, "metadata") or {}
uid = _sget(meta, "user_id")
if uid:
return int(uid)
except Exception:
pass
return None
def _resolve_plan_from_price(price_id: str) -> str:
"""Map Stripe price ID to internal plan name."""
for plan, pid in PLAN_PRICE_IDS.items():
if pid and pid == price_id:
return plan
# Unknown price — default to premium (safer than pro)
return "premium"
def _handle_checkout_completed(db, event):
"""checkout.session.completed → activate subscription for the user."""
session = event["data"]["object"]
customer_id = _sget(session, "customer")
subscription_id = _sget(session, "subscription")
metadata = _sget(session, "metadata") or {}
plan = _sget(metadata, "plan") or "premium"
user_id = _sget(metadata, "user_id")
if user_id:
user_id = int(user_id)
else:
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
logger.error(
"checkout.session.completed: cannot resolve user for customer %s",
customer_id,
)
return
# Fetch subscription details from Stripe
current_period_end = None
if subscription_id:
try:
sub = stripe.Subscription.retrieve(subscription_id)
current_period_end = datetime.fromtimestamp(
sub["current_period_end"], tz=timezone.utc
).isoformat()
# Sync plan from price if metadata plan is missing
if sub["items"]["data"]:
price_id = sub["items"]["data"][0]["price"]["id"]
plan = _resolve_plan_from_price(price_id)
except Exception as e:
logger.warning("Could not fetch subscription %s: %s", subscription_id, e)
_upsert_subscription(
db,
user_id,
plan=plan,
stripe_customer_id=customer_id,
stripe_subscription_id=subscription_id,
status="active",
current_period_end=current_period_end,
grace_period_end=None,
)
_update_user_plan(db, user_id, plan)
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info("checkout.session.completed: user %s upgraded to %s", user_id, plan)
def _handle_subscription_updated(db, event):
"""customer.subscription.updated → sync status and plan."""
sub_obj = event["data"]["object"]
customer_id = _sget(sub_obj, "customer")
subscription_id = _sget(sub_obj, "id")
stripe_status = _sget(sub_obj, "status") or "active"
current_period_end = None
cpe = _sget(sub_obj, "current_period_end")
if cpe:
current_period_end = datetime.fromtimestamp(cpe, tz=timezone.utc).isoformat()
# Resolve plan from price
plan = "premium"
items_data = _sget(_sget(sub_obj, "items") or {}, "data")
if items_data:
price_id = items_data[0]["price"]["id"]
plan = _resolve_plan_from_price(price_id)
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
# Try metadata
meta = _sget(sub_obj, "metadata") or {}
meta_uid = _sget(meta, "user_id")
if meta_uid:
user_id = int(meta_uid)
if not user_id:
logger.error(
"subscription.updated: cannot resolve user for customer %s", customer_id
)
return
_upsert_subscription(
db,
user_id,
plan=plan,
stripe_customer_id=customer_id,
stripe_subscription_id=subscription_id,
status=stripe_status,
current_period_end=current_period_end,
)
_update_user_plan(db, user_id, plan)
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info(
"subscription.updated: user %s plan=%s status=%s", user_id, plan, stripe_status
)
def _handle_subscription_deleted(db, event):
"""customer.subscription.deleted → downgrade to free."""
sub_obj = event["data"]["object"]
customer_id = _sget(sub_obj, "customer")
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
meta = _sget(sub_obj, "metadata") or {}
meta_uid = _sget(meta, "user_id")
if meta_uid:
user_id = int(meta_uid)
if not user_id:
logger.error(
"subscription.deleted: cannot resolve user for customer %s", customer_id
)
return
_upsert_subscription(
db,
user_id,
plan="free",
stripe_subscription_id=None,
status="canceled",
end_date=datetime.now(timezone.utc).isoformat(),
current_period_end=None,
grace_period_end=None,
)
_update_user_plan(db, user_id, "free")
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info("subscription.deleted: user %s downgraded to free", user_id)
def _handle_payment_failed(db, event):
"""invoice.payment_failed → mark past_due + 3-day grace period."""
invoice = event["data"]["object"]
customer_id = _sget(invoice, "customer")
subscription_id = _sget(invoice, "subscription")
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
logger.error(
"invoice.payment_failed: cannot resolve user for customer %s", customer_id
)
return
grace_end = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat()
_upsert_subscription(db, user_id, status="past_due", grace_period_end=grace_end)
_record_billing_event(
db,
event["id"],
event["type"],
user_id=user_id,
payload={"subscription_id": subscription_id},
)
# TODO: send notification email via /api/notifications
logger.warning(
"invoice.payment_failed: user %s past_due, grace period until %s",
user_id,
grace_end,
)
def _handle_payment_succeeded(db, event):
"""invoice.payment_succeeded → clear past_due / grace period."""
invoice = event["data"]["object"]
customer_id = _sget(invoice, "customer")
user_id = _resolve_user_from_customer(db, customer_id)
if not user_id:
return
# Refresh subscription period end
current_period_end = None
lines = _sget(invoice, "lines") or {}
lines_data = _sget(lines, "data") or []
if lines_data:
period = lines_data[0].get("period") or {}
period_end = (
period.get("end") if isinstance(period, dict) else _sget(period, "end")
)
if period_end:
current_period_end = datetime.fromtimestamp(
period_end, tz=timezone.utc
).isoformat()
_upsert_subscription(
db,
user_id,
status="active",
grace_period_end=None,
current_period_end=current_period_end,
)
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
# ──────────────────────────────────────────────────────────────
# On-import: ensure DB migration ran
# ──────────────────────────────────────────────────────────────
try:
migrate_billing_tables()
except Exception as _e:
logger.warning("billing_db migration skipped (test env?): %s", _e)

277
api_v1/routes/courses.py Normal file
View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
Courses routes for API v1.
GET /api/v1/courses/today — liste des courses du jour (public, paginated)
GET /api/v1/courses/{id}/predictions — prédictions ML pour une course (free tier, 1/day limit)
"""
import os
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request, g
from api_v1.utils import (
get_db,
table_exists,
error_response,
bad_request,
not_found,
internal_error,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, free_daily_limit_check
courses_bp = Blueprint("v1_courses", __name__, url_prefix="/api/v1/courses")
# ──────────────────────────────────────────────────────────────
# GET /api/v1/courses/today
# ──────────────────────────────────────────────────────────────
@courses_bp.route("/today", methods=["GET"])
@jwt_required_middleware
def courses_today():
"""
Courses du jour
---
tags:
- Courses
summary: Liste toutes les courses du jour avec info course
security:
- Bearer: []
parameters:
- name: filter
in: query
type: string
enum: [all, quinte, trot, plat]
default: all
description: Filtre par type de course
- name: limit
in: query
type: integer
default: 20
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Liste des courses du jour
401:
description: Token manquant ou invalide
"""
race_filter = request.args.get("filter", "all").lower()
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
today = datetime.now().strftime("%Y-%m-%d")
# Build SQL condition
if race_filter == "quinte":
cond = "AND (c.libelle LIKE '%Quinté%' OR c.libelle LIKE '%Quinte%')"
elif race_filter == "trot":
cond = "AND c.discipline LIKE '%Trot%'"
elif race_filter == "plat":
cond = "AND c.discipline LIKE '%Plat%'"
else:
cond = ""
conn = get_db()
try:
# Graceful handling if pmu_courses table doesn't exist yet
if not table_exists(conn, "pmu_courses"):
return jsonify(
{
"status": "ok",
"date": today,
"filter": race_filter,
"courses": [],
"pagination": {
"total": 0,
"limit": limit,
"offset": offset,
"has_more": False,
},
}
), 200
# Count total
count_row = conn.execute(
f"""SELECT COUNT(*) as cnt
FROM pmu_courses c
WHERE c.date_programme = ? {cond}""",
(today,),
).fetchone()
total = count_row["cnt"] if count_row else 0
rows = conn.execute(
f"""SELECT
c.date_programme,
c.num_reunion,
c.num_course,
c.libelle,
c.discipline,
c.distance,
c.hippodrome,
c.px_type,
COUNT(p.id_cheval) as nb_partants
FROM pmu_courses c
LEFT JOIN pmu_partants p
ON p.date_programme = c.date_programme
AND p.num_reunion = c.num_reunion
AND p.num_course = c.num_course
WHERE c.date_programme = ? {cond}
GROUP BY c.date_programme, c.num_reunion, c.num_course
ORDER BY c.num_reunion ASC, c.num_course ASC
LIMIT ? OFFSET ?""",
(today, limit, offset),
).fetchall()
courses = []
for r in rows:
course_id = f"{r['num_reunion']}-{r['num_course']}"
courses.append(
{
"id": course_id,
"date": r["date_programme"],
"num_reunion": r["num_reunion"],
"num_course": r["num_course"],
"libelle": r["libelle"],
"discipline": r["discipline"],
"distance": r["distance"],
"hippodrome": r["hippodrome"],
"type_pari": r["px_type"],
"nb_partants": r["nb_partants"],
}
)
pagination = paginate_query(courses, total, limit, offset)
return jsonify(
{
"status": "ok",
"date": today,
"filter": race_filter,
"courses": courses,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()
# ──────────────────────────────────────────────────────────────
# GET /api/v1/courses/<course_id>/predictions
# course_id format: "{num_reunion}-{num_course}" e.g. "1-3"
# ──────────────────────────────────────────────────────────────
@courses_bp.route("/<course_id>/predictions", methods=["GET"])
@jwt_required_middleware
@free_daily_limit_check
def course_predictions(course_id):
"""
Prédictions pour une course
---
tags:
- Courses
summary: Prédictions ML pour une course identifiée par {num_reunion}-{num_course}
security:
- Bearer: []
parameters:
- name: course_id
in: path
type: string
required: true
description: Identifiant de la course (format num_reunion-num_course, ex "1-3")
- name: date
in: query
type: string
format: date
description: Date de la course (YYYY-MM-DD), défaut = aujourd'hui
responses:
200:
description: Prédictions ML pour la course
400:
description: Paramètres invalides
404:
description: Course introuvable
429:
description: Limite quotidienne free tier atteinte
"""
# Parse course_id
parts = course_id.split("-")
if len(parts) != 2:
return bad_request(
"course_id doit être au format {num_reunion}-{num_course}, ex: 1-3"
)
try:
num_reunion = int(parts[0])
num_course = int(parts[1])
except ValueError:
return bad_request("num_reunion et num_course doivent être des entiers")
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
conn = get_db()
try:
# Fetch course info
course_row = conn.execute(
"""SELECT libelle, discipline, distance, hippodrome, px_type
FROM pmu_courses
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?""",
(date_param, num_reunion, num_course),
).fetchone()
if not course_row:
return not_found(
f"Course R{num_reunion}C{num_course} introuvable pour le {date_param}"
)
# Fetch ML predictions from cache
preds = []
if table_exists(conn, "ml_predictions_cache"):
preds = conn.execute(
"""SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ? AND num_reunion = ? AND num_course = ?
ORDER BY ml_score DESC""",
(date_param, num_reunion, num_course),
).fetchall()
# Fetch partants
partants = conn.execute(
"""SELECT nom, num_pmu, cote_direct, cote_reference, tendance_cote, favoris,
tx_victoire, tx_place, forme_recente, driver, entraineur, musique
FROM pmu_partants
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?
ORDER BY num_pmu ASC""",
(date_param, num_reunion, num_course),
).fetchall()
return jsonify(
{
"status": "ok",
"date": date_param,
"course": {
"id": course_id,
"libelle": course_row["libelle"],
"discipline": course_row["discipline"],
"distance": course_row["distance"],
"hippodrome": course_row["hippodrome"],
"type_pari": course_row["px_type"],
},
"predictions": [dict(p) for p in preds],
"partants": [dict(p) for p in partants],
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

185
api_v1/routes/export.py Normal file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Export route for API v1.
GET /api/v1/export/csv — Export CSV des prédictions ou paris (pro)
"""
import csv
import io
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request, Response
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
forbidden,
)
from auth import jwt_required_middleware, plan_required
export_bp = Blueprint("v1_export", __name__, url_prefix="/api/v1/export")
# Maximum rows exportable in one request
EXPORT_MAX_ROWS = 5000
@export_bp.route("/csv", methods=["GET"])
@jwt_required_middleware
@plan_required("pro")
def export_csv():
"""
Export CSV
---
tags:
- Export
summary: Export CSV des prédictions ML ou des paris historiques — accès pro uniquement
security:
- Bearer: []
parameters:
- name: type
in: query
type: string
enum: [predictions, bets]
default: predictions
description: Type de données à exporter
- name: start
in: query
type: string
format: date
description: Date de début (YYYY-MM-DD)
- name: end
in: query
type: string
format: date
description: Date de fin (YYYY-MM-DD)
- name: date
in: query
type: string
format: date
description: Date unique (YYYY-MM-DD), ignoré si start/end fournis
responses:
200:
description: Fichier CSV
content:
text/csv:
schema:
type: string
400:
description: Paramètre invalide
401:
description: Token invalide
403:
description: Plan insuffisant (pro requis)
"""
export_type = request.args.get("type", "predictions").lower()
if export_type not in ("predictions", "bets"):
return bad_request(
"Paramètre 'type' invalide. Valeurs acceptées: predictions, bets"
)
start = request.args.get("start")
end = request.args.get("end")
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
for label, val in [("start", start), ("end", end), ("date", date)]:
if val:
try:
datetime.strptime(val, "%Y-%m-%d")
except ValueError:
return bad_request(
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
)
# Build date range
if start and end:
date_cond = "date BETWEEN ? AND ?"
date_params = [start, end]
elif start:
date_cond = "date >= ?"
date_params = [start]
else:
date_cond = "date = ?"
date_params = [date]
conn = get_db()
try:
output = io.StringIO()
if export_type == "predictions":
if not table_exists(conn, "ml_predictions_cache"):
return bad_request("Table ml_predictions_cache introuvable")
rows = conn.execute(
f"""SELECT date, race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, risque_label
FROM ml_predictions_cache
WHERE {date_cond}
ORDER BY date DESC, ml_score DESC
LIMIT {EXPORT_MAX_ROWS}""",
date_params,
).fetchall()
fieldnames = [
"date",
"race_label",
"hippodrome",
"discipline",
"distance",
"heure",
"horse_name",
"horse_number",
"odds",
"prob_top1",
"prob_top3",
"ml_score",
"recommendation",
"is_value_bet",
"risque_label",
]
else: # bets
if not table_exists(conn, "bet_results"):
return bad_request("Table bet_results introuvable")
rows = conn.execute(
f"""SELECT date, race_name, type_pari, horse_name, horse_number,
COALESCE(cote, 0) AS cote, mise, resultat, gain
FROM bet_results
WHERE {date_cond}
ORDER BY date DESC
LIMIT {EXPORT_MAX_ROWS}""",
date_params,
).fetchall()
fieldnames = [
"date",
"race_name",
"type_pari",
"horse_name",
"horse_number",
"cote",
"mise",
"resultat",
"gain",
]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow(dict(row))
filename = f"turf_{export_type}_{date_params[0]}.csv"
return Response(
output.getvalue(),
status=200,
mimetype="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

44
api_v1/routes/health.py Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
GET /api/v1/health — public healthcheck endpoint.
No authentication required.
"""
from flask import Blueprint, jsonify
from datetime import datetime, timezone
health_bp = Blueprint("v1_health", __name__, url_prefix="/api/v1")
@health_bp.route("/health", methods=["GET"])
def health():
"""
Health check
---
tags:
- System
summary: Public healthcheck — returns API status and timestamp
responses:
200:
description: API is healthy
schema:
type: object
properties:
status:
type: string
example: ok
version:
type: string
example: "1.0"
timestamp:
type: string
format: date-time
"""
return jsonify(
{
"status": "ok",
"version": "1.0",
"api": "Turf SaaS API v1",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
), 200

144
api_v1/routes/metrics.py Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""
Metrics route for API v1.
GET /api/v1/metrics — Métriques performances ML (premium+)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
bad_request,
)
from auth import jwt_required_middleware, plan_required
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
@metrics_bp.route("/metrics", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def metrics():
"""
Métriques ML
---
tags:
- Métriques
summary: Métriques de performance du modèle ML (precision, ROI, top-3 rate) — premium+
security:
- Bearer: []
parameters:
- name: days
in: query
type: integer
default: 30
description: Nombre de jours à analyser (max 365)
responses:
200:
description: Métriques de performance ML
401:
description: Token invalide
403:
description: Plan insuffisant (premium ou pro requis)
"""
try:
days = int(request.args.get("days", 30))
except (ValueError, TypeError):
return bad_request("Paramètre 'days' doit être un entier")
days = max(1, min(days, 365))
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
conn = get_db()
try:
# ── Bet-level metrics from bet_results ──
bet_metrics = {
"available": False,
"period": {"start": start_date, "end": end_date, "days": days},
}
ml_metrics = {"available": False}
daily_stats = []
if table_exists(conn, "bet_results"):
row = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
SUM(mise) AS mise,
SUM(gain) AS gain
FROM bet_results
WHERE date BETWEEN ? AND ?""",
(start_date, end_date),
).fetchone()
total = row["total"] or 0
gagne = row["gagne"] or 0
mise = float(row["mise"] or 0)
gain = float(row["gain"] or 0)
bet_metrics = {
"available": True,
"period": {"start": start_date, "end": end_date, "days": days},
"total_bets": total,
"precision_pct": round(gagne / total * 100, 2) if total > 0 else 0.0,
"roi_pct": round((gain - mise) / mise * 100, 2) if mise > 0 else 0.0,
"mise_totale": round(mise, 2),
"gain_total": round(gain, 2),
}
# ── ML predictions cache metrics ──
if table_exists(conn, "ml_predictions_cache"):
cache_row = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(is_value_bet) AS value_bets,
AVG(prob_top1) AS avg_prob_top1,
AVG(prob_top3) AS avg_prob_top3,
AVG(ml_score) AS avg_ml_score
FROM ml_predictions_cache
WHERE date BETWEEN ? AND ?""",
(start_date, end_date),
).fetchone()
if cache_row and cache_row["total"]:
ml_metrics = {
"available": True,
"total_predictions": cache_row["total"],
"value_bets": cache_row["value_bets"] or 0,
"avg_prob_top1": round(float(cache_row["avg_prob_top1"] or 0), 4),
"avg_prob_top3": round(float(cache_row["avg_prob_top3"] or 0), 4),
"avg_ml_score": round(float(cache_row["avg_ml_score"] or 0), 4),
}
# ── Daily breakdown ──
if table_exists(conn, "daily_stats"):
daily_rows = conn.execute(
"""SELECT date, total_bets, bets_gagne, precision_pct, roi_pct,
mise_totale, gain_total
FROM daily_stats
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
LIMIT 60""",
(start_date, end_date),
).fetchall()
daily_stats = [dict(r) for r in daily_rows]
return jsonify(
{
"status": "ok",
"period": {"start": start_date, "end": end_date, "days": days},
"bet_metrics": bet_metrics,
"ml_metrics": ml_metrics,
"daily": daily_stats,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Predictions routes for API v1.
GET /api/v1/predictions/top3 — Top 3 global du jour (free tier, 1/day limit)
GET /api/v1/predictions/all — Toutes prédictions (premium+)
"""
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
not_found,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, plan_required, free_daily_limit_check
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
"""Shared helper — returns rows from ml_predictions_cache."""
if not table_exists(conn, "ml_predictions_cache"):
return [], 0
count_row = conn.execute(
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
(date,),
).fetchone()
total = count_row["cnt"] if count_row else 0
sql = """SELECT
race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ?
ORDER BY ml_score DESC"""
params = [date]
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params += [limit, offset]
rows = conn.execute(sql, params).fetchall()
return [dict(r) for r in rows], total
# ──────────────────────────────────────────────────────────────
# GET /api/v1/predictions/top3
# ──────────────────────────────────────────────────────────────
@predictions_bp.route("/top3", methods=["GET"])
@jwt_required_middleware
@free_daily_limit_check
def predictions_top3():
"""
Top 3 prédictions du jour
---
tags:
- Prédictions
summary: Top 3 chevaux avec le meilleur score ML du jour (free tier inclus)
security:
- Bearer: []
parameters:
- name: date
in: query
type: string
format: date
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
responses:
200:
description: Top 3 prédictions ML du jour
401:
description: Token invalide
429:
description: Limite quotidienne free tier atteinte
"""
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
conn = get_db()
try:
predictions, _ = _fetch_ml_predictions(conn, date_param, limit=3, offset=0)
return jsonify(
{
"status": "ok",
"date": date_param,
"top3": predictions,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()
# ──────────────────────────────────────────────────────────────
# GET /api/v1/predictions/all
# ──────────────────────────────────────────────────────────────
@predictions_bp.route("/all", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def predictions_all():
"""
Toutes les prédictions du jour
---
tags:
- Prédictions
summary: Toutes les prédictions ML du jour — accès premium et pro uniquement
security:
- Bearer: []
parameters:
- name: date
in: query
type: string
format: date
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
- name: limit
in: query
type: integer
default: 20
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Toutes les prédictions ML
401:
description: Token invalide
403:
description: Plan insuffisant (premium ou pro requis)
"""
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
conn = get_db()
try:
predictions, total = _fetch_ml_predictions(
conn, date_param, limit=limit, offset=offset
)
pagination = paginate_query(predictions, total, limit, offset)
return jsonify(
{
"status": "ok",
"date": date_param,
"predictions": predictions,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

111
api_v1/routes/valuebets.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Value bets route for API v1.
GET /api/v1/valuebets — Value bets du jour (premium+)
"""
from datetime import datetime
from flask import Blueprint, jsonify, request
from api_v1.utils import (
get_db,
table_exists,
internal_error,
get_pagination_params,
paginate_query,
)
from auth import jwt_required_middleware, plan_required
valuebets_bp = Blueprint("v1_valuebets", __name__, url_prefix="/api/v1")
@valuebets_bp.route("/valuebets", methods=["GET"])
@jwt_required_middleware
@plan_required("premium", "pro")
def valuebets():
"""
Value bets du jour
---
tags:
- Value Bets
summary: Value bets du jour — chevaux à cote surévaluée par le marché (premium+)
security:
- Bearer: []
parameters:
- name: date
in: query
type: string
format: date
description: Date YYYY-MM-DD (défaut aujourd'hui)
- name: min_odds
in: query
type: number
default: 2.0
description: Cote minimale pour filtrer les value bets
- name: limit
in: query
type: integer
default: 20
- name: offset
in: query
type: integer
default: 0
responses:
200:
description: Value bets du jour
401:
description: Token invalide
403:
description: Plan insuffisant (premium ou pro requis)
"""
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
limit, offset = get_pagination_params(default_limit=20, max_limit=100)
try:
min_odds = float(request.args.get("min_odds", 2.0))
except (ValueError, TypeError):
min_odds = 2.0
conn = get_db()
try:
rows = []
total = 0
if table_exists(conn, "ml_predictions_cache"):
count_row = conn.execute(
"""SELECT COUNT(*) as cnt
FROM ml_predictions_cache
WHERE date = ? AND is_value_bet = 1 AND odds >= ?""",
(date_param, min_odds),
).fetchone()
total = count_row["cnt"] if count_row else 0
rows = conn.execute(
"""SELECT race_label, hippodrome, discipline, distance, heure,
horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, risque_label, risque_score
FROM ml_predictions_cache
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
ORDER BY ml_score DESC
LIMIT ? OFFSET ?""",
(date_param, min_odds, limit, offset),
).fetchall()
valuebets_list = [dict(r) for r in rows]
pagination = paginate_query(valuebets_list, total, limit, offset)
return jsonify(
{
"status": "ok",
"date": date_param,
"min_odds": min_odds,
"valuebets": valuebets_list,
**pagination,
}
), 200
except Exception as e:
return internal_error(str(e))
finally:
conn.close()

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,
}
}

138
app_v1.py Normal file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
app_v1.py — Turf SaaS Flask application with versioned API /v1/
This module creates the Flask app, registers:
- Auth JWT (from Sprint 2-3)
- API v1 blueprints
- Swagger/OpenAPI documentation at /api/v1/docs
Usage:
python app_v1.py
# or via gunicorn:
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
Sprint 3-4: HRT-29 — Refacto API /v1/
"""
import os
import logging
from datetime import timedelta
from flask import Flask, jsonify
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from flasgger import Swagger
from auth_db import init_auth_tables
from auth import auth_bp
from api_v1 import register_api_v1
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("turf_saas.app_v1")
def create_app() -> Flask:
"""Application factory."""
app = Flask(__name__)
# ── CORS ──
CORS(app, origins=["*"], methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
# ── JWT config ──
app.config["JWT_SECRET_KEY"] = os.environ.get(
"JWT_SECRET_KEY", "change-me-in-production-use-strong-random-secret"
)
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
JWTManager(app)
# ── Swagger / OpenAPI ──
swagger_config = {
"headers": [],
"specs": [
{
"endpoint": "apispec_v1",
"route": "/api/v1/apispec.json",
"rule_filter": lambda rule: str(rule).startswith("/api/v1"),
"model_filter": lambda tag: True,
}
],
"static_url_path": "/flasgger_static",
"swagger_ui": True,
"specs_route": "/api/v1/docs",
}
swagger_template = {
"swagger": "2.0",
"info": {
"title": "Turf SaaS API",
"description": (
"API v1 — Prédictions turf IA, value bets, backtest & métriques.\n\n"
"**Plans:** `free` | `premium` | `pro`\n\n"
"**Auth:** Bearer JWT — obtenir un token via `POST /api/v1/auth/login`"
),
"version": "1.0.0",
"contact": {"name": "H3R7 Tech"},
},
"basePath": "/",
"schemes": ["http", "https"],
"securityDefinitions": {
"Bearer": {
"type": "apiKey",
"name": "Authorization",
"in": "header",
"description": "Entrer: **Bearer &lt;token&gt;**",
}
},
"consumes": ["application/json"],
"produces": ["application/json"],
}
Swagger(app, config=swagger_config, template=swagger_template)
# ── Auth DB init ──
with app.app_context():
try:
init_auth_tables()
except Exception as e:
logger.warning("init_auth_tables warning: %s", e)
# ── Register auth blueprint ──
app.register_blueprint(auth_bp)
# ── Register API v1 blueprints ──
register_api_v1(app)
# ── Global error handlers ──
@app.errorhandler(404)
def not_found_handler(e):
return jsonify(
{"status": "error", "message": "Route introuvable", "code": 404}
), 404
@app.errorhandler(405)
def method_not_allowed_handler(e):
return jsonify(
{"status": "error", "message": "Méthode non autorisée", "code": 405}
), 405
@app.errorhandler(500)
def internal_error_handler(e):
logger.exception("Unhandled 500 error")
return jsonify(
{"status": "error", "message": "Erreur serveur interne", "code": 500}
), 500
logger.info("Turf SaaS API v1 ready — docs at /api/v1/docs")
return app
app = create_app()
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8792))
app.run(host="0.0.0.0", port=port, debug=False)

362
auth.py Normal file
View File

@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""
Auth Blueprint — JWT authentication + multi-tenant plan enforcement
Sprint 2-3: HRT-28
Endpoints:
POST /api/v1/auth/register — email/password registration
POST /api/v1/auth/login — returns access_token (15min) + refresh_token (30d)
POST /api/v1/auth/refresh — rotate refresh token, issue new access_token
POST /api/v1/auth/logout — revoke refresh token
Middleware exposed:
jwt_required_middleware() — decorator: valid access JWT required
plan_required(plans) — decorator: user plan must be in given list
"""
import os
import hashlib
import secrets
import logging
from datetime import datetime, timedelta, timezone
from functools import wraps
import bcrypt
from flask import Blueprint, request, jsonify, g, current_app
from flask_jwt_extended import (
JWTManager,
create_access_token,
create_refresh_token,
decode_token,
get_jwt_identity,
verify_jwt_in_request,
)
from flask_jwt_extended.exceptions import JWTExtendedException
from jwt.exceptions import PyJWTError
from auth_db import get_db
logger = logging.getLogger("turf_saas.auth")
auth_bp = Blueprint("auth", __name__, url_prefix="/api/v1/auth")
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
def _hash_token(raw_token: str) -> str:
"""SHA-256 hash of a token string for secure DB storage."""
return hashlib.sha256(raw_token.encode()).hexdigest()
def _get_user_by_email(email: str):
db = get_db()
user = db.execute(
"SELECT * FROM users WHERE email = ? AND is_active = 1", (email.lower(),)
).fetchone()
db.close()
return user
def _get_user_by_id(user_id: int):
db = get_db()
user = db.execute(
"SELECT * FROM users WHERE id = ? AND is_active = 1", (user_id,)
).fetchone()
db.close()
return user
def _store_refresh_token(user_id: int, raw_token: str, expires_at: datetime):
token_hash = _hash_token(raw_token)
db = get_db()
db.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?,?,?)",
(user_id, token_hash, expires_at.isoformat()),
)
db.commit()
db.close()
def _revoke_refresh_token(raw_token: str):
token_hash = _hash_token(raw_token)
db = get_db()
db.execute(
"UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,)
)
db.commit()
db.close()
def _is_refresh_token_valid(raw_token: str, user_id: int) -> bool:
token_hash = _hash_token(raw_token)
db = get_db()
row = db.execute(
"""SELECT id FROM refresh_tokens
WHERE token_hash = ? AND user_id = ? AND revoked = 0
AND expires_at > datetime('now')""",
(token_hash, user_id),
).fetchone()
db.close()
return row is not None
# ──────────────────────────────────────────────────────────────
# Auth endpoints
# ──────────────────────────────────────────────────────────────
@auth_bp.route("/register", methods=["POST"])
def register():
"""POST /api/v1/auth/register — create a new user account (plan=free)."""
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or "@" not in email:
return jsonify({"error": "Email invalide"}), 400
if len(password) < 8:
return jsonify({"error": "Mot de passe trop court (min 8 caractères)"}), 400
# Check uniqueness
existing = _get_user_by_email(email)
if existing:
return jsonify({"error": "Email déjà enregistré"}), 409
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
db = get_db()
try:
cursor = db.execute(
"INSERT INTO users (email, password_hash, plan) VALUES (?,?,?)",
(email, password_hash, "free"),
)
user_id = cursor.lastrowid
# Create initial subscription record
db.execute(
"INSERT INTO subscriptions (user_id, plan) VALUES (?,?)",
(user_id, "free"),
)
db.commit()
except Exception as e:
db.rollback()
logger.error("register error: %s", e)
return jsonify({"error": "Erreur interne"}), 500
finally:
db.close()
logger.info("New user registered: %s (id=%s)", email, user_id)
return jsonify({"message": "Compte créé avec succès", "user_id": user_id}), 201
@auth_bp.route("/login", methods=["POST"])
def login():
"""POST /api/v1/auth/login — returns JWT access_token + refresh_token."""
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
return jsonify({"error": "Email et mot de passe requis"}), 400
user = _get_user_by_email(email)
if not user:
return jsonify({"error": "Identifiants invalides"}), 401
if not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
logger.warning("Failed login attempt for %s", email)
return jsonify({"error": "Identifiants invalides"}), 401
# Create tokens
identity = str(user["id"])
additional_claims = {"plan": user["plan"], "email": user["email"]}
access_token = create_access_token(
identity=identity,
additional_claims=additional_claims,
)
raw_refresh = create_refresh_token(identity=identity)
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
_store_refresh_token(user["id"], raw_refresh, refresh_expires)
logger.info("User %s logged in (plan=%s)", email, user["plan"])
return jsonify(
{
"access_token": access_token,
"refresh_token": raw_refresh,
"token_type": "Bearer",
"plan": user["plan"],
}
), 200
@auth_bp.route("/refresh", methods=["POST"])
def refresh():
"""POST /api/v1/auth/refresh — rotate refresh token, issue new access_token."""
data = request.get_json(silent=True) or {}
raw_refresh = (data.get("refresh_token") or "").strip()
if not raw_refresh:
return jsonify({"error": "refresh_token manquant"}), 400
# Decode without verifying in DB first (to get user_id)
try:
decoded = decode_token(raw_refresh)
except Exception:
return jsonify({"error": "Refresh token invalide ou expiré"}), 401
user_id = int(decoded.get("sub", 0))
if not _is_refresh_token_valid(raw_refresh, user_id):
return jsonify({"error": "Refresh token invalide, révoqué ou expiré"}), 401
user = _get_user_by_id(user_id)
if not user:
return jsonify({"error": "Utilisateur introuvable"}), 401
# Revoke old refresh token (rotation)
_revoke_refresh_token(raw_refresh)
# Issue new tokens
identity = str(user["id"])
additional_claims = {"plan": user["plan"], "email": user["email"]}
new_access = create_access_token(
identity=identity, additional_claims=additional_claims
)
new_refresh = create_refresh_token(identity=identity)
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
_store_refresh_token(user["id"], new_refresh, refresh_expires)
logger.info("Token refreshed for user_id=%s", user_id)
return jsonify(
{
"access_token": new_access,
"refresh_token": new_refresh,
"token_type": "Bearer",
"plan": user["plan"],
}
), 200
@auth_bp.route("/logout", methods=["POST"])
def logout():
"""POST /api/v1/auth/logout — revoke refresh token."""
data = request.get_json(silent=True) or {}
raw_refresh = (data.get("refresh_token") or "").strip()
if raw_refresh:
_revoke_refresh_token(raw_refresh)
return jsonify({"message": "Déconnexion réussie"}), 200
# ──────────────────────────────────────────────────────────────
# JWT-protected middleware
# ──────────────────────────────────────────────────────────────
def jwt_required_middleware(fn):
"""Decorator: require a valid Bearer JWT access token."""
@wraps(fn)
def wrapper(*args, **kwargs):
try:
verify_jwt_in_request()
user_id = int(get_jwt_identity())
user = _get_user_by_id(user_id)
if not user:
return jsonify({"error": "Utilisateur introuvable"}), 401
g.current_user = dict(user)
g.current_user_id = user_id
except (JWTExtendedException, PyJWTError) as e:
logger.debug("JWT auth failed: %s", e)
return jsonify({"error": "Token invalide ou expiré", "detail": str(e)}), 401
return fn(*args, **kwargs)
return wrapper
def plan_required(*allowed_plans):
"""
Decorator factory: user's plan must be in allowed_plans.
Must be applied AFTER @jwt_required_middleware.
Example:
@app.route("/api/v1/predictions")
@jwt_required_middleware
@plan_required("premium", "pro")
def premium_predictions():
...
"""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
user = getattr(g, "current_user", None)
if not user:
return jsonify({"error": "Non authentifié"}), 401
if user["plan"] not in allowed_plans:
return jsonify(
{
"error": "Plan insuffisant",
"required": list(allowed_plans),
"current_plan": user["plan"],
"upgrade_url": "/api/v1/subscription/upgrade",
}
), 403
return fn(*args, **kwargs)
return wrapper
return decorator
def free_daily_limit_check(fn):
"""
Decorator: enforce free plan daily limit (1 course/jour).
Must be applied AFTER @jwt_required_middleware.
"""
@wraps(fn)
def wrapper(*args, **kwargs):
user = getattr(g, "current_user", None)
if not user or user["plan"] != "free":
return fn(*args, **kwargs)
today = datetime.now(timezone.utc).date().isoformat()
db = get_db()
row = db.execute(
"SELECT daily_usage, last_usage_date FROM users WHERE id = ?",
(user["id"],),
).fetchone()
db.close()
if row and row["last_usage_date"] == today and row["daily_usage"] >= 1:
return jsonify(
{
"error": "Limite quotidienne atteinte (plan free: 1 course/jour)",
"upgrade_url": "/api/v1/subscription/upgrade",
}
), 429
# Increment usage
db = get_db()
if row and row["last_usage_date"] == today:
db.execute(
"UPDATE users SET daily_usage = daily_usage + 1 WHERE id = ?",
(user["id"],),
)
else:
db.execute(
"UPDATE users SET daily_usage = 1, last_usage_date = ? WHERE id = ?",
(today, user["id"]),
)
db.commit()
db.close()
return fn(*args, **kwargs)
return wrapper

68
auth_db.py Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Auth DB — users and subscriptions schema for turf_saas.db
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
"""
import sqlite3
import os
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_auth_tables():
"""Create users and subscriptions tables if they don't exist."""
conn = get_db()
c = conn.cursor()
c.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free'
CHECK(plan IN ('free','premium','pro')),
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
is_active INTEGER NOT NULL DEFAULT 1,
daily_usage INTEGER NOT NULL DEFAULT 0,
last_usage_date TEXT DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
plan TEXT NOT NULL CHECK(plan IN ('free','premium','pro')),
start_date DATETIME NOT NULL DEFAULT (datetime('now')),
end_date DATETIME,
stripe_customer_id TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
expires_at DATETIME NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
""")
conn.commit()
conn.close()
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
if __name__ == "__main__":
init_auth_tables()

127
billing_db.py Normal file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
DB Migration — Billing Stripe
Sprint 5-6: HRT-31
Adds stripe_subscription_id and status columns to subscriptions table,
and an invoices / grace-period tracking table.
Run once:
./venv/bin/python billing_db.py
"""
import sqlite3
import os
import logging
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
logger = logging.getLogger("turf_saas.billing_db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def migrate_billing_tables():
"""Idempotent migration: add billing columns and billing_events table.
Requires auth tables (users, subscriptions) to exist first.
Calls init_auth_tables() automatically if subscriptions is absent.
"""
from auth_db import init_auth_tables as _init_auth
conn = get_db()
c = conn.cursor()
# Ensure base auth tables exist
tables = {
row[0] for row in c.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
conn.close()
if "subscriptions" not in tables:
_init_auth()
conn = get_db()
c = conn.cursor()
# Add stripe_subscription_id if missing
columns = {row[1] for row in c.execute("PRAGMA table_info(subscriptions)")}
if "stripe_subscription_id" not in columns:
c.execute("ALTER TABLE subscriptions ADD COLUMN stripe_subscription_id TEXT")
logger.info("[billing_db] Added stripe_subscription_id column to subscriptions")
if "status" not in columns:
c.execute(
"ALTER TABLE subscriptions ADD COLUMN "
"status TEXT NOT NULL DEFAULT 'active' "
"CHECK(status IN ('active','past_due','canceled','trialing','incomplete'))"
)
logger.info("[billing_db] Added status column to subscriptions")
if "grace_period_end" not in columns:
c.execute("ALTER TABLE subscriptions ADD COLUMN grace_period_end DATETIME")
logger.info("[billing_db] Added grace_period_end column to subscriptions")
if "current_period_end" not in columns:
c.execute("ALTER TABLE subscriptions ADD COLUMN current_period_end DATETIME")
logger.info("[billing_db] Added current_period_end column to subscriptions")
# billing_events table — audit trail for all webhook events
c.executescript("""
CREATE TABLE IF NOT EXISTS billing_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stripe_event_id TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
user_id INTEGER REFERENCES users(id),
payload TEXT,
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id);
CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type);
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id);
""")
conn.commit()
conn.close()
print(
"[billing_db] Migration complete: subscriptions + billing_events tables ready."
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
migrate_billing_tables()
# ──────────────────────────────────────────────────────────────
# Re-exported helpers for test usage
# (primary implementations live in api_v1/routes/billing.py)
# ──────────────────────────────────────────────────────────────
def _upsert_subscription(db, user_id: int, **fields):
"""
Update existing subscription row or insert a new one.
Convenience re-export for test helpers.
"""
existing = db.execute(
"SELECT id FROM subscriptions WHERE user_id = ? ORDER BY start_date DESC LIMIT 1",
(user_id,),
).fetchone()
if existing:
set_parts = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [existing["id"]]
db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values)
else:
cols = ", ".join(["user_id"] + list(fields.keys()))
placeholders = ", ".join(["?"] * (1 + len(fields)))
values = [user_id] + list(fields.values())
db.execute(
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
)

90
middleware.py Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
Middleware — rate limiting, CORS, and access logging
Sprint 2-3: HRT-28
"""
import logging
import time
from collections import defaultdict
from datetime import datetime, timezone
from functools import wraps
from threading import Lock
from flask import request, jsonify, g
logger = logging.getLogger("turf_saas.middleware")
# ──────────────────────────────────────────────────────────────
# In-memory rate limiter (100 req/min per IP)
# For production: replace with Redis-backed counter
# ──────────────────────────────────────────────────────────────
_rate_store: dict = defaultdict(lambda: {"count": 0, "window_start": 0.0})
_rate_lock = Lock()
RATE_LIMIT = 100 # max requests
RATE_WINDOW = 60 # seconds
def rate_limit_middleware(app):
"""Register before_request rate limiting on the Flask app."""
@app.before_request
def check_rate_limit():
ip = request.remote_addr or "unknown"
now = time.time()
with _rate_lock:
bucket = _rate_store[ip]
if now - bucket["window_start"] >= RATE_WINDOW:
bucket["count"] = 0
bucket["window_start"] = now
bucket["count"] += 1
count = bucket["count"]
remaining = max(0, RATE_LIMIT - count)
if count > RATE_LIMIT:
logger.warning("Rate limit exceeded for IP %s", ip)
resp = jsonify({"error": "Trop de requêtes. Limite: 100/min par IP."})
resp.status_code = 429
resp.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
resp.headers["X-RateLimit-Remaining"] = "0"
resp.headers["Retry-After"] = str(RATE_WINDOW)
return resp
# Attach headers on all responses via after_request
g.rl_remaining = remaining
# ──────────────────────────────────────────────────────────────
# Access logs (timestamped)
# ──────────────────────────────────────────────────────────────
access_log = logging.getLogger("turf_saas.access")
def access_log_middleware(app):
"""Register after_request access logging on the Flask app."""
@app.after_request
def log_access(response):
ip = request.remote_addr or "unknown"
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
user_id = getattr(g, "current_user_id", "-")
access_log.info(
'%s %s %s "%s %s" %s %s',
ts,
ip,
user_id,
request.method,
request.path,
response.status_code,
response.content_length or 0,
)
# Attach rate-limit headers
remaining = getattr(g, "rl_remaining", None)
if remaining is not None:
response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
response.headers["X-RateLimit-Remaining"] = str(remaining)
return response

247
saas_api.py Normal file
View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
Turf SaaS API v1 — Auth JWT + Multi-tenant
Sprint 2-3: HRT-28
Run:
FLASK_ENV=development ./venv/bin/python saas_api.py
Ports (isolated from production):
Portal: 8793
SaaS API: 8792 ← this file
Dashboard: 8791
Combined API: 8790
"""
import os
import logging
import logging.handlers
import sys
from flask import Flask, jsonify, g, request
from flask_cors import CORS
from flask_jwt_extended import JWTManager, get_jwt
from auth_db import init_auth_tables
from auth import (
auth_bp,
jwt_required_middleware,
plan_required,
free_daily_limit_check,
_get_user_by_id,
)
from middleware import rate_limit_middleware, access_log_middleware
# ──────────────────────────────────────────────────────────────
# Logging setup
# ──────────────────────────────────────────────────────────────
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.handlers.RotatingFileHandler(
os.path.join(LOG_DIR, "saas_api.log"),
maxBytes=5 * 1024 * 1024,
backupCount=3,
),
],
)
# ──────────────────────────────────────────────────────────────
# App factory
# ──────────────────────────────────────────────────────────────
def create_app(test_config=None):
app = Flask(__name__)
# JWT config
app.config["JWT_SECRET_KEY"] = os.environ.get(
"JWT_SECRET_KEY", "CHANGE_ME_IN_PRODUCTION_" + os.urandom(24).hex()
)
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 900 # 15 minutes
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = 2592000 # 30 days
if test_config:
app.config.update(test_config)
# CORS — SaaS domain + localhost for dev
CORS(
app,
origins=os.environ.get(
"CORS_ORIGINS",
"http://localhost:8793,http://127.0.0.1:8793,https://turf-ia.h3r7.tech",
).split(","),
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
supports_credentials=True,
)
# JWT
jwt = JWTManager(app)
# ── JWT error handlers ────────────────────────────────────
@jwt.expired_token_loader
def expired_token(_jwt_header, _jwt_payload):
return jsonify({"error": "Token expiré"}), 401
@jwt.invalid_token_loader
def invalid_token(reason):
return jsonify({"error": "Token invalide", "detail": reason}), 422
@jwt.unauthorized_loader
def unauthorized(reason):
return jsonify({"error": "Token manquant ou invalide", "detail": reason}), 401
# ── Register middleware ───────────────────────────────────
rate_limit_middleware(app)
access_log_middleware(app)
# ── Blueprints ────────────────────────────────────────────
app.register_blueprint(auth_bp)
# ── Predictions routes (multi-tenant plan check) ──────────
@app.route("/api/v1/predictions", methods=["GET"])
@jwt_required_middleware
@free_daily_limit_check
def predictions():
"""
GET /api/v1/predictions
- free: Top 3 uniquement (déjà filtrées par le moteur ML)
- premium: toutes courses + alertes Telegram
- pro: API complète + export CSV disponible
"""
user = g.current_user
plan = user["plan"]
# Forward to combined_api for actual predictions
import requests as req
try:
params = dict(request.args)
resp = req.get(
"http://localhost:8790/api/predictions",
params=params,
timeout=10,
)
data = resp.json()
except Exception as e:
return jsonify(
{"error": "Service prédictions indisponible", "detail": str(e)}
), 503
# Plan filtering
if plan == "free":
# Top 3 only
if isinstance(data, list):
data = [
{k: v for k, v in p.items() if k not in ("score_detaille",)}
for p in data[:3]
]
return jsonify({"plan": plan, "predictions": data, "limit": "Top 3"}), 200
elif plan == "premium":
# All courses, but no CSV export
return jsonify(
{"plan": plan, "predictions": data, "telegram_alerts": True}
), 200
else: # pro
return jsonify(
{
"plan": plan,
"predictions": data,
"telegram_alerts": True,
"csv_export_url": "/api/v1/predictions/export",
}
), 200
@app.route("/api/v1/predictions/export", methods=["GET"])
@jwt_required_middleware
@plan_required("pro")
def predictions_export():
"""CSV export — pro plan only."""
import requests as req
import io
try:
resp = req.get(
"http://localhost:8790/api/predictions/export",
params=dict(request.args),
timeout=15,
)
from flask import Response
return Response(
resp.content,
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=predictions.csv"},
)
except Exception as e:
return jsonify({"error": "Export indisponible", "detail": str(e)}), 503
@app.route("/api/v1/subscription/upgrade", methods=["GET"])
@jwt_required_middleware
def subscription_info():
"""Return available plans and current user plan."""
user = g.current_user
return jsonify(
{
"current_plan": user["plan"],
"plans": {
"free": {
"price": "0€/mois",
"features": ["Top 3 prédictions", "1 course/jour"],
},
"premium": {
"price": "9.99€/mois",
"features": [
"Toutes les courses",
"Alertes Telegram",
"Historique 30j",
],
},
"pro": {
"price": "29.99€/mois",
"features": [
"API complète",
"Export CSV",
"Alertes Telegram",
"Historique illimité",
"Support prioritaire",
],
},
},
"upgrade_contact": "contact@h3r7.tech",
}
), 200
# ── Health check ──────────────────────────────────────────
@app.route("/api/v1/health", methods=["GET"])
def health():
return jsonify(
{"status": "ok", "service": "turf-saas-api", "version": "2.3.0"}
), 200
# Init DB tables on startup
with app.app_context():
init_auth_tables()
return app
# ──────────────────────────────────────────────────────────────
# Entrypoint
# ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
app = create_app()
port = int(os.environ.get("SAAS_API_PORT", 8792))
app.run(host="0.0.0.0", port=port, debug=False)

473
tests/test_api_v1.py Normal file
View File

@@ -0,0 +1,473 @@
#!/usr/bin/env python3
"""
Integration tests for API v1 — HRT-29
Sprint 3-4: Refacto API /v1/
Run with:
cd /home/h3r7/turf_saas
source venv/bin/activate
python -m pytest tests/test_api_v1.py -v
"""
import json
import os
import sys
import tempfile
import pytest
# Ensure local modules are importable
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Use a temp file DB for tests (in-memory fails with multiple connections)
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp_db.close()
os.environ["TURF_SAAS_DB"] = _tmp_db.name
os.environ["JWT_SECRET_KEY"] = "test-secret-key"
from app_v1 import create_app
from auth_db import init_auth_tables
# ──────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def app():
application = create_app()
application.config["TESTING"] = True
application.config["JWT_SECRET_KEY"] = "test-secret-key"
yield application
@pytest.fixture(scope="module")
def client(app):
return app.test_client()
@pytest.fixture(scope="module")
def auth_tokens(client):
"""Register a user and return tokens for each plan."""
tokens = {}
plans = {
"free": ("free@test.com", "password123"),
"premium": ("premium@test.com", "password123"),
"pro": ("pro@test.com", "password123"),
}
# Register users
for plan, (email, pw) in plans.items():
r = client.post(
"/api/v1/auth/register",
json={"email": email, "password": pw},
content_type="application/json",
)
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
# Manually set plans in DB using direct sqlite (bypass app context issues)
import sqlite3
db_path = os.environ.get("TURF_SAAS_DB", "/tmp/test_turf.db")
conn = sqlite3.connect(db_path)
for plan, (email, _) in plans.items():
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
conn.commit()
conn.close()
# Login and collect tokens
for plan, (email, pw) in plans.items():
r = client.post(
"/api/v1/auth/login",
json={"email": email, "password": pw},
content_type="application/json",
)
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
data = r.get_json()
tokens[plan] = data["access_token"]
return tokens
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
# ──────────────────────────────────────────────────────────────
# Health
# ──────────────────────────────────────────────────────────────
class TestHealth:
def test_health_public(self, client):
"""GET /api/v1/health — no auth required"""
r = client.get("/api/v1/health")
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert data["version"] == "1.0"
assert "timestamp" in data
def test_health_returns_json(self, client):
r = client.get("/api/v1/health")
assert r.content_type.startswith("application/json")
# ──────────────────────────────────────────────────────────────
# Auth
# ──────────────────────────────────────────────────────────────
class TestAuth:
def test_register_new_user(self, client):
r = client.post(
"/api/v1/auth/register",
json={"email": "new_test@example.com", "password": "strongpass123"},
)
assert r.status_code in (201, 409)
def test_register_short_password(self, client):
r = client.post(
"/api/v1/auth/register",
json={"email": "bad@example.com", "password": "123"},
)
assert r.status_code == 400
def test_register_invalid_email(self, client):
r = client.post(
"/api/v1/auth/register",
json={"email": "notemail", "password": "password123"},
)
assert r.status_code == 400
def test_login_valid(self, client, auth_tokens):
assert "free" in auth_tokens
def test_login_wrong_password(self, client):
r = client.post(
"/api/v1/auth/login",
json={"email": "free@test.com", "password": "wrongpassword"},
)
assert r.status_code == 401
def test_protected_without_token(self, client):
r = client.get("/api/v1/courses/today")
assert r.status_code == 401
# ──────────────────────────────────────────────────────────────
# Courses
# ──────────────────────────────────────────────────────────────
class TestCourses:
def test_today_requires_auth(self, client):
r = client.get("/api/v1/courses/today")
assert r.status_code == 401
def test_today_with_auth(self, client, auth_tokens):
r = client.get(
"/api/v1/courses/today",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert "courses" in data
assert "pagination" in data
assert "date" in data
def test_today_pagination(self, client, auth_tokens):
r = client.get(
"/api/v1/courses/today?limit=5&offset=0",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["pagination"]["limit"] == 5
assert data["pagination"]["offset"] == 0
def test_today_filter_all(self, client, auth_tokens):
r = client.get(
"/api/v1/courses/today?filter=all",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 200
def test_course_predictions_requires_auth(self, client):
r = client.get("/api/v1/courses/1-1/predictions")
assert r.status_code == 401
def test_course_predictions_invalid_id(self, client, auth_tokens):
r = client.get(
"/api/v1/courses/invalid/predictions",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 400
def test_course_predictions_not_found(self, client, auth_tokens):
r = client.get(
"/api/v1/courses/99-99/predictions",
headers=auth_header(auth_tokens["free"]),
)
# 404 expected since DB is empty; 429 if free daily limit already reached in this session
assert r.status_code in (404, 200, 429) # 200 if gracefully returns empty
# ──────────────────────────────────────────────────────────────
# Predictions
# ──────────────────────────────────────────────────────────────
class TestPredictions:
def test_top3_requires_auth(self, client):
r = client.get("/api/v1/predictions/top3")
assert r.status_code == 401
def test_top3_free_allowed(self, client, auth_tokens):
# Reset daily usage for free user before testing rate-limited endpoint
import sqlite3
db_path = os.environ.get("TURF_SAAS_DB", "/tmp/test_turf.db")
conn = sqlite3.connect(db_path)
conn.execute(
"UPDATE users SET daily_usage=0, last_usage_date=NULL WHERE email='free@test.com'"
)
conn.commit()
conn.close()
r = client.get(
"/api/v1/predictions/top3",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert "top3" in data
def test_all_requires_premium(self, client, auth_tokens):
r = client.get(
"/api/v1/predictions/all",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 403
def test_all_premium_allowed(self, client, auth_tokens):
r = client.get(
"/api/v1/predictions/all",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert "predictions" in data
assert "pagination" in data
def test_all_pro_allowed(self, client, auth_tokens):
r = client.get(
"/api/v1/predictions/all",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
# ──────────────────────────────────────────────────────────────
# Value Bets
# ──────────────────────────────────────────────────────────────
class TestValueBets:
def test_requires_auth(self, client):
r = client.get("/api/v1/valuebets")
assert r.status_code == 401
def test_free_forbidden(self, client, auth_tokens):
r = client.get(
"/api/v1/valuebets",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 403
def test_premium_allowed(self, client, auth_tokens):
r = client.get(
"/api/v1/valuebets",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert "valuebets" in data
assert "pagination" in data
def test_min_odds_filter(self, client, auth_tokens):
r = client.get(
"/api/v1/valuebets?min_odds=3.0",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["min_odds"] == 3.0
# ──────────────────────────────────────────────────────────────
# Backtest
# ──────────────────────────────────────────────────────────────
class TestBacktest:
def test_requires_auth(self, client):
r = client.get("/api/v1/backtest")
assert r.status_code == 401
def test_premium_forbidden(self, client, auth_tokens):
r = client.get(
"/api/v1/backtest",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 403
def test_pro_allowed(self, client, auth_tokens):
r = client.get(
"/api/v1/backtest",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert "summary" in data
assert "period" in data
def test_invalid_date_format(self, client, auth_tokens):
r = client.get(
"/api/v1/backtest?start=31-12-2025",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 400
# ──────────────────────────────────────────────────────────────
# Export
# ──────────────────────────────────────────────────────────────
class TestExport:
def test_requires_auth(self, client):
r = client.get("/api/v1/export/csv")
assert r.status_code == 401
def test_free_forbidden(self, client, auth_tokens):
r = client.get(
"/api/v1/export/csv",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 403
def test_premium_forbidden(self, client, auth_tokens):
r = client.get(
"/api/v1/export/csv",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 403
def test_pro_allowed_predictions(self, client, auth_tokens):
r = client.get(
"/api/v1/export/csv?type=predictions",
headers=auth_header(auth_tokens["pro"]),
)
# 200 (CSV) or 400 if table doesn't exist in test DB
assert r.status_code in (200, 400)
if r.status_code == 200:
assert "text/csv" in r.content_type
def test_invalid_type(self, client, auth_tokens):
r = client.get(
"/api/v1/export/csv?type=invalid",
headers=auth_header(auth_tokens["pro"]),
)
assert r.status_code == 400
# ──────────────────────────────────────────────────────────────
# Metrics
# ──────────────────────────────────────────────────────────────
class TestMetrics:
def test_requires_auth(self, client):
r = client.get("/api/v1/metrics")
assert r.status_code == 401
def test_free_forbidden(self, client, auth_tokens):
r = client.get(
"/api/v1/metrics",
headers=auth_header(auth_tokens["free"]),
)
assert r.status_code == 403
def test_premium_allowed(self, client, auth_tokens):
r = client.get(
"/api/v1/metrics",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["status"] == "ok"
assert "bet_metrics" in data
assert "ml_metrics" in data
assert "period" in data
def test_days_parameter(self, client, auth_tokens):
r = client.get(
"/api/v1/metrics?days=7",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 200
data = r.get_json()
assert data["period"]["days"] == 7
def test_invalid_days(self, client, auth_tokens):
r = client.get(
"/api/v1/metrics?days=abc",
headers=auth_header(auth_tokens["premium"]),
)
assert r.status_code == 400
# ──────────────────────────────────────────────────────────────
# Global error handlers
# ──────────────────────────────────────────────────────────────
class TestErrorHandlers:
def test_404_returns_json(self, client):
r = client.get("/api/v1/this-does-not-exist")
assert r.status_code == 404
data = r.get_json()
assert data["code"] == 404
def test_uniform_error_shape(self, client):
"""All error responses must have {status, message, code}."""
r = client.get("/api/v1/this-does-not-exist")
data = r.get_json()
assert "status" in data
assert "message" in data
assert "code" in data
# ──────────────────────────────────────────────────────────────
# Swagger docs
# ──────────────────────────────────────────────────────────────
class TestDocs:
def test_docs_accessible(self, client):
r = client.get("/api/v1/docs")
# flasgger returns a redirect or the UI page
assert r.status_code in (200, 301, 302)
def test_apispec_json(self, client):
r = client.get("/api/v1/apispec.json")
assert r.status_code == 200
spec = r.get_json()
assert spec["swagger"] == "2.0"
assert "paths" in spec

404
tests/test_auth.py Normal file
View File

@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""
Pytest tests — Auth JWT + Multi-tenant
Sprint 2-3: HRT-28
Coverage target: >= 80%
Run:
./venv/bin/pytest tests/test_auth.py -v --tb=short
./venv/bin/pytest tests/test_auth.py -v --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing
"""
import os
import sys
import tempfile
import json
import pytest
# Point to a temp SQLite DB for tests
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp_db.close()
os.environ["TURF_SAAS_DB"] = _tmp_db.name
os.environ["JWT_SECRET_KEY"] = "test-secret-key-for-pytest"
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from saas_api import create_app # noqa: E402
TEST_CONFIG = {
"TESTING": True,
"JWT_SECRET_KEY": "test-secret-key-for-pytest",
"JWT_ACCESS_TOKEN_EXPIRES": 900,
"JWT_REFRESH_TOKEN_EXPIRES": 2592000,
}
@pytest.fixture(scope="module")
def app():
application = create_app(TEST_CONFIG)
yield application
@pytest.fixture(scope="module")
def client(app):
return app.test_client()
# ──────────────────────────────────────────────────────────────
# Health
# ──────────────────────────────────────────────────────────────
class TestHealth:
def test_health_ok(self, client):
resp = client.get("/api/v1/health")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "ok"
assert data["service"] == "turf-saas-api"
# ──────────────────────────────────────────────────────────────
# Registration
# ──────────────────────────────────────────────────────────────
class TestRegister:
def test_register_success(self, client):
resp = client.post(
"/api/v1/auth/register",
json={"email": "user_test@example.com", "password": "password123"},
)
assert resp.status_code == 201
data = resp.get_json()
assert "user_id" in data
def test_register_duplicate(self, client):
client.post(
"/api/v1/auth/register",
json={"email": "dup@example.com", "password": "password123"},
)
resp = client.post(
"/api/v1/auth/register",
json={"email": "dup@example.com", "password": "password123"},
)
assert resp.status_code == 409
def test_register_invalid_email(self, client):
resp = client.post(
"/api/v1/auth/register",
json={"email": "notanemail", "password": "password123"},
)
assert resp.status_code == 400
def test_register_short_password(self, client):
resp = client.post(
"/api/v1/auth/register",
json={"email": "shortpw@example.com", "password": "abc"},
)
assert resp.status_code == 400
def test_register_missing_fields(self, client):
resp = client.post("/api/v1/auth/register", json={})
assert resp.status_code == 400
# ──────────────────────────────────────────────────────────────
# Login
# ──────────────────────────────────────────────────────────────
class TestLogin:
@pytest.fixture(autouse=True)
def create_user(self, client):
client.post(
"/api/v1/auth/register",
json={"email": "login@example.com", "password": "loginpass1"},
)
def test_login_success(self, client):
resp = client.post(
"/api/v1/auth/login",
json={"email": "login@example.com", "password": "loginpass1"},
)
assert resp.status_code == 200
data = resp.get_json()
assert "access_token" in data
assert "refresh_token" in data
assert data["plan"] == "free"
def test_login_wrong_password(self, client):
resp = client.post(
"/api/v1/auth/login",
json={"email": "login@example.com", "password": "wrongpass"},
)
assert resp.status_code == 401
def test_login_unknown_email(self, client):
resp = client.post(
"/api/v1/auth/login",
json={"email": "ghost@example.com", "password": "anypass"},
)
assert resp.status_code == 401
def test_login_missing_fields(self, client):
resp = client.post("/api/v1/auth/login", json={"email": "login@example.com"})
assert resp.status_code == 400
# ──────────────────────────────────────────────────────────────
# Token refresh
# ──────────────────────────────────────────────────────────────
class TestRefresh:
@pytest.fixture(autouse=True)
def setup(self, client):
client.post(
"/api/v1/auth/register",
json={"email": "refresh@example.com", "password": "refreshpass1"},
)
resp = client.post(
"/api/v1/auth/login",
json={"email": "refresh@example.com", "password": "refreshpass1"},
)
tokens = resp.get_json()
self.refresh_token = tokens["refresh_token"]
def test_refresh_success(self, client):
resp = client.post(
"/api/v1/auth/refresh",
json={"refresh_token": self.refresh_token},
)
assert resp.status_code == 200
data = resp.get_json()
assert "access_token" in data
assert "refresh_token" in data
# New refresh token should differ from old
assert data["refresh_token"] != self.refresh_token
def test_refresh_token_rotation(self, client):
"""Old refresh token must be invalid after rotation."""
client.post(
"/api/v1/auth/refresh",
json={"refresh_token": self.refresh_token},
)
resp2 = client.post(
"/api/v1/auth/refresh",
json={"refresh_token": self.refresh_token},
)
assert resp2.status_code == 401
def test_refresh_invalid_token(self, client):
resp = client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "completely.invalid.token"},
)
assert resp.status_code == 401
def test_refresh_missing_token(self, client):
resp = client.post("/api/v1/auth/refresh", json={})
assert resp.status_code == 400
# ──────────────────────────────────────────────────────────────
# Logout
# ──────────────────────────────────────────────────────────────
class TestLogout:
@pytest.fixture(autouse=True)
def setup(self, client):
client.post(
"/api/v1/auth/register",
json={"email": "logout@example.com", "password": "logoutpass1"},
)
resp = client.post(
"/api/v1/auth/login",
json={"email": "logout@example.com", "password": "logoutpass1"},
)
tokens = resp.get_json()
self.refresh_token = tokens["refresh_token"]
self.access_token = tokens["access_token"]
def test_logout_success(self, client):
resp = client.post(
"/api/v1/auth/logout",
json={"refresh_token": self.refresh_token},
)
assert resp.status_code == 200
def test_refresh_after_logout_fails(self, client):
client.post("/api/v1/auth/logout", json={"refresh_token": self.refresh_token})
resp = client.post(
"/api/v1/auth/refresh",
json={"refresh_token": self.refresh_token},
)
assert resp.status_code == 401
def test_logout_no_token(self, client):
resp = client.post("/api/v1/auth/logout", json={})
assert resp.status_code == 200
# ──────────────────────────────────────────────────────────────
# JWT middleware — protected routes
# ──────────────────────────────────────────────────────────────
class TestJWTMiddleware:
@pytest.fixture(autouse=True)
def setup(self, client):
client.post(
"/api/v1/auth/register",
json={"email": "protected@example.com", "password": "protect123"},
)
resp = client.post(
"/api/v1/auth/login",
json={"email": "protected@example.com", "password": "protect123"},
)
self.access_token = resp.get_json()["access_token"]
def test_subscription_info_requires_auth(self, client):
resp = client.get("/api/v1/subscription/upgrade")
assert resp.status_code == 401
def test_subscription_info_with_token(self, client):
resp = client.get(
"/api/v1/subscription/upgrade",
headers={"Authorization": f"Bearer {self.access_token}"},
)
assert resp.status_code == 200
data = resp.get_json()
assert "current_plan" in data
assert data["current_plan"] == "free"
def test_invalid_token_rejected(self, client):
resp = client.get(
"/api/v1/subscription/upgrade",
headers={"Authorization": "Bearer invalid.token.here"},
)
assert resp.status_code in (401, 422)
# ──────────────────────────────────────────────────────────────
# Plan checks
# ──────────────────────────────────────────────────────────────
class TestPlanMiddleware:
@pytest.fixture(autouse=True)
def setup(self, client, app):
# Register free user
client.post(
"/api/v1/auth/register",
json={"email": "free_plan@example.com", "password": "freepass1"},
)
resp = client.post(
"/api/v1/auth/login",
json={"email": "free_plan@example.com", "password": "freepass1"},
)
self.free_token = resp.get_json()["access_token"]
# Upgrade user to pro directly in DB for testing
import sqlite3
db_path = os.environ["TURF_SAAS_DB"]
conn = sqlite3.connect(db_path)
conn.execute(
"INSERT OR IGNORE INTO users (email, password_hash, plan) VALUES (?,?,?)",
("pro_plan@example.com", "$2b$12$placeholder", "pro"),
)
conn.commit()
conn.close()
# Login pro user using JWT created manually via app context
with app.app_context():
from flask_jwt_extended import create_access_token
conn = sqlite3.connect(db_path)
row = conn.execute(
"SELECT id FROM users WHERE email='pro_plan@example.com'"
).fetchone()
conn.close()
self.pro_token = create_access_token(
identity=str(row[0]),
additional_claims={"plan": "pro", "email": "pro_plan@example.com"},
)
def test_export_blocked_for_free(self, client):
resp = client.get(
"/api/v1/predictions/export",
headers={"Authorization": f"Bearer {self.free_token}"},
)
assert resp.status_code == 403
data = resp.get_json()
assert "Plan insuffisant" in data["error"]
def test_export_allowed_for_pro(self, client):
resp = client.get(
"/api/v1/predictions/export",
headers={"Authorization": f"Bearer {self.pro_token}"},
)
# 503 is expected because no backend is running; 403 would be wrong
assert resp.status_code != 403
def test_upgrade_info_shows_plans(self, client):
resp = client.get(
"/api/v1/subscription/upgrade",
headers={"Authorization": f"Bearer {self.free_token}"},
)
assert resp.status_code == 200
data = resp.get_json()
assert "free" in data["plans"]
assert "premium" in data["plans"]
assert "pro" in data["plans"]
# ──────────────────────────────────────────────────────────────
# Rate limiting
# ──────────────────────────────────────────────────────────────
class TestRateLimiting:
def test_rate_limit_headers_present(self, client):
resp = client.get("/api/v1/health")
assert "X-RateLimit-Limit" in resp.headers
assert resp.headers["X-RateLimit-Limit"] == "100"
def test_rate_limit_remaining_decreases(self, client):
r1 = client.get("/api/v1/health")
r2 = client.get("/api/v1/health")
rem1 = int(r1.headers.get("X-RateLimit-Remaining", 100))
rem2 = int(r2.headers.get("X-RateLimit-Remaining", 100))
assert rem2 <= rem1
# ──────────────────────────────────────────────────────────────
# DB module
# ──────────────────────────────────────────────────────────────
class TestAuthDB:
def test_tables_exist(self):
import sqlite3
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
tables = {
r[0]
for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
assert "users" in tables
assert "subscriptions" in tables
assert "refresh_tokens" in tables
conn.close()
def test_get_db_returns_connection(self):
from auth_db import get_db
db = get_db()
assert db is not None
db.close()