Compare commits
4 Commits
feature/ml
...
feature/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce0ee150ec | ||
|
|
b8ef1ed35d | ||
|
|
c8f1bfd478 | ||
|
|
5a23692ad1 |
132
API_AUTH.md
Normal file
132
API_AUTH.md
Normal 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
156
README_API_V1.md
Normal 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
43
api_v1/__init__.py
Normal 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)
|
||||||
0
api_v1/routes/__init__.py
Normal file
0
api_v1/routes/__init__.py
Normal file
195
api_v1/routes/backtest.py
Normal file
195
api_v1/routes/backtest.py
Normal 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
664
api_v1/routes/billing.py
Normal 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
277
api_v1/routes/courses.py
Normal 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
185
api_v1/routes/export.py
Normal 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
44
api_v1/routes/health.py
Normal 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
144
api_v1/routes/metrics.py
Normal 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()
|
||||||
163
api_v1/routes/predictions.py
Normal file
163
api_v1/routes/predictions.py
Normal 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
111
api_v1/routes/valuebets.py
Normal 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
98
api_v1/utils.py
Normal 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
138
app_v1.py
Normal 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 <token>**",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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
362
auth.py
Normal 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
68
auth_db.py
Normal 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
127
billing_db.py
Normal 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
90
middleware.py
Normal 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
247
saas_api.py
Normal 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
473
tests/test_api_v1.py
Normal 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
404
tests/test_auth.py
Normal 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()
|
||||||
Reference in New Issue
Block a user